init
这个提交包含在:
当前提交
9e8571402d
24
.gitignore
vendored
普通文件
24
.gitignore
vendored
普通文件
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
5
README.md
普通文件
5
README.md
普通文件
@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
41
package.json
普通文件
41
package.json
普通文件
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "electron-vue3-ts",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"main": "src/main/main.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"yarn serve\" \"yarn electron-dev\"",
|
||||||
|
"serve": "vite --host",
|
||||||
|
"electron-dev": "wait-on tcp:5173 && cross-env NODE_ENV=development electron .",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"crypto-ts": "^1.0.2",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"fs-extra": "^11.3.2",
|
||||||
|
"multer": "^2.0.2",
|
||||||
|
"sqlite": "^5.1.1",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.5",
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"@vue/tsconfig": "^0.8.1",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"electron": "38.2.2",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"vite": "^7.1.7",
|
||||||
|
"vite-plugin-electron": "^0.29.0",
|
||||||
|
"vite-plugin-electron-renderer": "^0.14.6",
|
||||||
|
"vue-tsc": "^3.1.0",
|
||||||
|
"wait-on": "^9.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
普通文件
1
public/vite.svg
普通文件
@ -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>
|
||||||
|
之后 宽度: | 高度: | 大小: 1.5 KiB |
165
server/server.js
普通文件
165
server/server.js
普通文件
@ -0,0 +1,165 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const cors = require('cors')
|
||||||
|
const multer = require('multer')
|
||||||
|
const path = require('path')
|
||||||
|
const fs = require('fs-extra')
|
||||||
|
const { calculateFileMD5 } = require('./utils.js')
|
||||||
|
const { initDatabase, FileService } = require('../database/database.js')
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const PORT = 3000
|
||||||
|
|
||||||
|
// 初始化数据库
|
||||||
|
initDatabase()
|
||||||
|
const fileService = new FileService()
|
||||||
|
|
||||||
|
// 确保上传目录存在
|
||||||
|
const uploadDir = path.join(process.cwd(), 'uploads')
|
||||||
|
fs.ensureDirSync(uploadDir)
|
||||||
|
|
||||||
|
// 配置 multer - 修复中文文件名问题
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, uploadDir)
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
// 处理中文文件名 - 使用原始文件名但确保安全
|
||||||
|
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8')
|
||||||
|
const ext = path.extname(originalName)
|
||||||
|
const name = path.basename(originalName, ext)
|
||||||
|
|
||||||
|
// 清理文件名,移除特殊字符
|
||||||
|
const safeName = name.replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_')
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9)
|
||||||
|
const filename = safeName + '-' + uniqueSuffix + ext
|
||||||
|
|
||||||
|
cb(null, filename)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
// 处理文件名编码
|
||||||
|
file.originalname = Buffer.from(file.originalname, 'latin1').toString('utf8')
|
||||||
|
cb(null, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置响应头,确保使用 UTF-8 编码
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.use(cors())
|
||||||
|
app.use(express.json({ limit: '50mb' }))
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }))
|
||||||
|
|
||||||
|
// 文件上传接口
|
||||||
|
app.post('/api/upload', upload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保文件名正确编码
|
||||||
|
const originalName = Buffer.from(req.file.originalname, 'latin1').toString('utf8')
|
||||||
|
|
||||||
|
const fileInfo = {
|
||||||
|
originalName: originalName,
|
||||||
|
fileName: req.file.filename,
|
||||||
|
filePath: req.file.path,
|
||||||
|
fileSize: req.file.size,
|
||||||
|
mimeType: req.file.mimetype
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算 MD5
|
||||||
|
const md5 = await calculateFileMD5(req.file.path)
|
||||||
|
|
||||||
|
// 保存到数据库
|
||||||
|
const fileRecord = await fileService.createFile({
|
||||||
|
...fileInfo,
|
||||||
|
md5
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: fileRecord
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error)
|
||||||
|
res.status(500).json({ error: 'Upload failed: ' + error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 获取文件列表(分页)
|
||||||
|
app.get('/api/files', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const page = parseInt(req.query.page) || 1
|
||||||
|
const pageSize = parseInt(req.query.pageSize) || 10
|
||||||
|
|
||||||
|
const result = await fileService.getFilesPaginated(page, pageSize)
|
||||||
|
|
||||||
|
// 确保返回的数据使用 UTF-8 编码
|
||||||
|
res.json(result)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get files error:', error)
|
||||||
|
res.status(500).json({ error: 'Failed to get files' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 其他接口保持不变...
|
||||||
|
app.post('/api/files/:id/check-md5', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const fileId = parseInt(req.params.id)
|
||||||
|
const file = await fileService.getFileById(fileId)
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return res.status(404).json({ error: 'File not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMD5 = await calculateFileMD5(file.filePath)
|
||||||
|
const isChanged = currentMD5 !== file.md5
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
isChanged,
|
||||||
|
currentMD5,
|
||||||
|
originalMD5: file.md5,
|
||||||
|
file
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MD5 check error:', error)
|
||||||
|
res.status(500).json({ error: 'MD5 check failed' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/files/:id/update-md5', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const fileId = parseInt(req.params.id)
|
||||||
|
const { md5 } = req.body
|
||||||
|
|
||||||
|
await fileService.updateFileMD5(fileId, md5)
|
||||||
|
res.json({ success: true })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update MD5 error:', error)
|
||||||
|
res.status(500).json({ error: 'Update failed' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 健康检查接口
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'OK',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
service: 'file-management-api'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function startServer() {
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server running on http://localhost:${PORT}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startServer }
|
||||||
15
server/utils.js
普通文件
15
server/utils.js
普通文件
@ -0,0 +1,15 @@
|
|||||||
|
const crypto = require('crypto')
|
||||||
|
const fs = require('fs')
|
||||||
|
|
||||||
|
function calculateFileMD5(filePath) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const hash = crypto.createHash('md5')
|
||||||
|
const stream = fs.createReadStream(filePath)
|
||||||
|
|
||||||
|
stream.on('data', (data) => hash.update(data))
|
||||||
|
stream.on('end', () => resolve(hash.digest('hex')))
|
||||||
|
stream.on('error', (error) => reject(error))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { calculateFileMD5 }
|
||||||
1
src/assets/vue.svg
普通文件
1
src/assets/vue.svg
普通文件
@ -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>
|
||||||
|
之后 宽度: | 高度: | 大小: 496 B |
二进制
src/main/assets/icon.ico
普通文件
二进制
src/main/assets/icon.ico
普通文件
二进制文件未显示。
|
之后 宽度: | 高度: | 大小: 15 KiB |
245
src/main/main.js
普通文件
245
src/main/main.js
普通文件
@ -0,0 +1,245 @@
|
|||||||
|
const { app, BrowserWindow, Tray, Menu, ipcMain } = require('electron')
|
||||||
|
const path = require('path')
|
||||||
|
const { startServer } = require('../../server/server.js')
|
||||||
|
const net = require('net')
|
||||||
|
const dns = require('dns')
|
||||||
|
|
||||||
|
let mainWindow
|
||||||
|
let tray
|
||||||
|
let isQuitting = false
|
||||||
|
let currentApiBaseUrl = 'http://localhost:3000' // 默认使用本地服务
|
||||||
|
let isLocalServerRunning = false
|
||||||
|
|
||||||
|
// 配置外网服务地址(这里需要替换为你的实际外网服务地址)
|
||||||
|
const REMOTE_API_BASE = 'http://localhost:3000' // 替换为你的外网服务地址
|
||||||
|
|
||||||
|
// 检查是否为开发模式
|
||||||
|
function isDev() {
|
||||||
|
return process.env.NODE_ENV === 'development' ||
|
||||||
|
process.defaultApp ||
|
||||||
|
/[\\/]electron[\\/]/.test(process.execPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查网络连接状态
|
||||||
|
async function checkNetworkConnectivity() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// 方法1: 检查是否能解析外网域名
|
||||||
|
dns.lookup('baidu.com', (err) => {
|
||||||
|
if (!err) {
|
||||||
|
resolve(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方法2: 尝试建立TCP连接到常见端口
|
||||||
|
const socket = new net.Socket()
|
||||||
|
const timeout = 5000 // 5秒超时
|
||||||
|
|
||||||
|
socket.setTimeout(timeout)
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
socket.on('error', () => {
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 尝试连接到常见服务的端口
|
||||||
|
socket.connect(80, '8.8.8.8')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查外网服务是否可用
|
||||||
|
async function checkRemoteService() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${REMOTE_API_BASE}/api/health`, {
|
||||||
|
method: 'GET',
|
||||||
|
timeout: 5000
|
||||||
|
})
|
||||||
|
return response.ok
|
||||||
|
} catch (error) {
|
||||||
|
console.log('外网服务不可用:', error.message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定使用哪个API服务
|
||||||
|
async function determineApiService() {
|
||||||
|
const hasNetwork = await checkNetworkConnectivity()
|
||||||
|
|
||||||
|
if (hasNetwork) {
|
||||||
|
console.log('检测到网络连接,检查外网服务...')
|
||||||
|
const remoteAvailable = await checkRemoteService()
|
||||||
|
|
||||||
|
if (remoteAvailable) {
|
||||||
|
console.log('使用外网服务:', REMOTE_API_BASE)
|
||||||
|
return {
|
||||||
|
baseUrl: REMOTE_API_BASE,
|
||||||
|
isLocal: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('使用本地服务')
|
||||||
|
// 启动本地服务器
|
||||||
|
if (!isLocalServerRunning) {
|
||||||
|
try {
|
||||||
|
startServer()
|
||||||
|
isLocalServerRunning = true
|
||||||
|
console.log('本地服务器启动成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('本地服务器启动失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: 'http://localhost:3000',
|
||||||
|
isLocal: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定期检查网络状态(每30秒)
|
||||||
|
function startNetworkMonitoring() {
|
||||||
|
setInterval(async () => {
|
||||||
|
const serviceInfo = await determineApiService()
|
||||||
|
if (serviceInfo.baseUrl !== currentApiBaseUrl) {
|
||||||
|
console.log('API服务地址变更:', currentApiBaseUrl, '->', serviceInfo.baseUrl)
|
||||||
|
currentApiBaseUrl = serviceInfo.baseUrl
|
||||||
|
|
||||||
|
// 通知渲染进程服务地址变更
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.webContents.send('api-base-url-changed', currentApiBaseUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000) // 30秒检查一次
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
preload: path.join(__dirname, 'preload.js')
|
||||||
|
},
|
||||||
|
show: false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 在开发模式下,等待 Vite 服务器启动
|
||||||
|
if (isDev()) {
|
||||||
|
console.log('开发模式:等待 Vite 服务器启动...')
|
||||||
|
|
||||||
|
// 给 Vite 服务器一些时间启动
|
||||||
|
setTimeout(() => {
|
||||||
|
mainWindow.loadURL('http://localhost:5173')
|
||||||
|
mainWindow.webContents.openDevTools()
|
||||||
|
}, 3000)
|
||||||
|
} else {
|
||||||
|
// 生产模式
|
||||||
|
mainWindow.loadFile(path.join(__dirname, '../../dist/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
if (!isQuitting) {
|
||||||
|
event.preventDefault()
|
||||||
|
mainWindow.hide()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
// 当页面加载失败时尝试重新加载
|
||||||
|
mainWindow.webContents.on('did-fail-load', () => {
|
||||||
|
if (isDev()) {
|
||||||
|
console.log('页面加载失败,尝试重新加载...')
|
||||||
|
setTimeout(() => {
|
||||||
|
mainWindow.loadURL('http://localhost:5173')
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTray() {
|
||||||
|
// 暂时不使用托盘图标,避免图标文件问题
|
||||||
|
tray = new Tray(path.join(__dirname, './assets/icon.ico').replace('app.asar', ''))
|
||||||
|
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: '打开主界面',
|
||||||
|
click: () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '隐藏主界面',
|
||||||
|
click: () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '退出',
|
||||||
|
click: () => {
|
||||||
|
isQuitting = true
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
tray.setToolTip('Electron Vue Client')
|
||||||
|
tray.setContextMenu(contextMenu)
|
||||||
|
|
||||||
|
tray.on('double-click', () => {
|
||||||
|
if (mainWindow) {
|
||||||
|
mainWindow.show()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
// 确定API服务
|
||||||
|
const serviceInfo = await determineApiService()
|
||||||
|
currentApiBaseUrl = serviceInfo.baseUrl
|
||||||
|
|
||||||
|
createWindow()
|
||||||
|
createTray()
|
||||||
|
|
||||||
|
// 启动网络监控
|
||||||
|
startNetworkMonitoring()
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') app.quit()
|
||||||
|
})
|
||||||
|
|
||||||
|
// IPC 通信处理
|
||||||
|
ipcMain.handle('get-app-version', () => {
|
||||||
|
return app.getVersion()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('get-api-base-url', () => {
|
||||||
|
return currentApiBaseUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听服务地址变更
|
||||||
|
ipcMain.on('request-api-base-url', (event) => {
|
||||||
|
event.reply('api-base-url', currentApiBaseUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
isQuitting = true
|
||||||
|
})
|
||||||
17
src/main/preload.js
普通文件
17
src/main/preload.js
普通文件
@ -0,0 +1,17 @@
|
|||||||
|
const { contextBridge, ipcRenderer } = require('electron')
|
||||||
|
|
||||||
|
// 暴露安全的API给渲染进程
|
||||||
|
contextBridge.exposeInMainWorld('electronAPI', {
|
||||||
|
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||||
|
getApiBaseUrl: () => ipcRenderer.invoke('get-api-base-url'),
|
||||||
|
|
||||||
|
// 监听API基础URL变化
|
||||||
|
onApiBaseUrlChange: (callback) => {
|
||||||
|
ipcRenderer.on('api-base-url-changed', (event, url) => callback(url))
|
||||||
|
},
|
||||||
|
|
||||||
|
// 移除监听器
|
||||||
|
removeAllListeners: (channel) => {
|
||||||
|
ipcRenderer.removeAllListeners(channel)
|
||||||
|
}
|
||||||
|
})
|
||||||
5
src/renderer/App.vue
普通文件
5
src/renderer/App.vue
普通文件
@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
382
src/renderer/components/FileList.vue
普通文件
382
src/renderer/components/FileList.vue
普通文件
@ -0,0 +1,382 @@
|
|||||||
|
<template>
|
||||||
|
<div class="file-list">
|
||||||
|
<div class="file-list-header">
|
||||||
|
<h2>文件列表</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button @click="refreshList" class="refresh-button">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
<div class="network-status" :class="isOnline ? 'online' : 'offline'">
|
||||||
|
{{ isOnline ? '在线模式' : '离线模式' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="file-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>文件名</th>
|
||||||
|
<th>大小</th>
|
||||||
|
<th>MD5</th>
|
||||||
|
<th>上传时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="file in files" :key="file.id">
|
||||||
|
<td class="filename-cell">{{ decodeFileName(file.originalName) }}</td>
|
||||||
|
<td>{{ formatFileSize(file.fileSize) }}</td>
|
||||||
|
<td class="md5-cell">{{ file.md5 }}</td>
|
||||||
|
<td>{{ formatDate(file.createdAt) }}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
@click="checkFileMD5(file)"
|
||||||
|
class="check-button"
|
||||||
|
:disabled="checkingFileId === file.id"
|
||||||
|
>
|
||||||
|
{{ checkingFileId === file.id ? '检查中...' : '检查MD5' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn-ocr"
|
||||||
|
@click="handleOcr(file)"
|
||||||
|
title="OCR识别"
|
||||||
|
>
|
||||||
|
🔍 OCR
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="files.length === 0" class="empty-state">
|
||||||
|
暂无文件
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination" v-if="files.length > 0">
|
||||||
|
<button
|
||||||
|
@click="prevPage"
|
||||||
|
:disabled="pagination.page === 1"
|
||||||
|
class="page-button"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
<span class="page-info">
|
||||||
|
第 {{ pagination.page }} 页 / 共 {{ pagination.totalPages }} 页
|
||||||
|
(总计 {{ pagination.total }} 个文件)
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click="nextPage"
|
||||||
|
:disabled="pagination.page >= pagination.totalPages"
|
||||||
|
class="page-button"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import apiManager from '../utils/apiManager.ts'
|
||||||
|
|
||||||
|
interface FileRecord {
|
||||||
|
id: number
|
||||||
|
originalName: string
|
||||||
|
fileName: string
|
||||||
|
filePath: string
|
||||||
|
fileSize: number
|
||||||
|
mimeType: string
|
||||||
|
md5: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationInfo {
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
total: number
|
||||||
|
totalPages: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileListResponse {
|
||||||
|
files: FileRecord[]
|
||||||
|
pagination: PaginationInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MD5CheckResponse {
|
||||||
|
isChanged: boolean
|
||||||
|
currentMD5: string
|
||||||
|
originalMD5: string
|
||||||
|
file: FileRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = ref<FileRecord[]>([])
|
||||||
|
const pagination = ref<PaginationInfo>({
|
||||||
|
page: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
totalPages: 0
|
||||||
|
})
|
||||||
|
const checkingFileId = ref<number | null>(null)
|
||||||
|
const isOnline = ref(false)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'md5Check', file: FileRecord, isChanged: boolean, newMD5: string): void
|
||||||
|
(event: 'ocrRecognize', file: FileRecord): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 监听网络状态变化
|
||||||
|
const handleNetworkChange = (event: any) => {
|
||||||
|
isOnline.value = event.detail.isOnline
|
||||||
|
console.log('文件列表: 网络状态变更', event.detail)
|
||||||
|
|
||||||
|
// 网络状态变化时重新加载文件列表
|
||||||
|
loadFiles(1)
|
||||||
|
}
|
||||||
|
const handleOcr = (file: FileRecord): void => {
|
||||||
|
emit('ocrRecognize', file)
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
isOnline.value = apiManager.isOnlineMode()
|
||||||
|
window.addEventListener('apiBaseUrlChanged', handleNetworkChange)
|
||||||
|
loadFiles()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('apiBaseUrlChanged', handleNetworkChange)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 解码文件名显示
|
||||||
|
const decodeFileName = (fileName: string): string => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(fileName)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('文件名解码失败,使用原文件名:', error)
|
||||||
|
return fileName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFiles = async (page: number = pagination.value.page): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const result: FileListResponse = await apiManager.request(
|
||||||
|
`/api/files?page=${page}&pageSize=${pagination.value.pageSize}`
|
||||||
|
)
|
||||||
|
|
||||||
|
files.value = result.files
|
||||||
|
pagination.value = result.pagination
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载文件列表失败:', error)
|
||||||
|
alert('加载文件列表失败,请检查服务是否正常运行')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkFileMD5 = async (file: FileRecord): Promise<void> => {
|
||||||
|
checkingFileId.value = file.id
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result: MD5CheckResponse = await apiManager.request(
|
||||||
|
`/api/files/${file.id}/check-md5`,
|
||||||
|
{ method: 'POST' }
|
||||||
|
)
|
||||||
|
|
||||||
|
emit('md5Check', file, result.isChanged, result.currentMD5)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('MD5检查失败:', error)
|
||||||
|
alert('检查MD5失败,请检查文件是否存在')
|
||||||
|
} finally {
|
||||||
|
checkingFileId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshList = (): void => {
|
||||||
|
loadFiles(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPage = (): void => {
|
||||||
|
if (pagination.value.page < pagination.value.totalPages) {
|
||||||
|
loadFiles(pagination.value.page + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevPage = (): void => {
|
||||||
|
if (pagination.value.page > 1) {
|
||||||
|
loadFiles(pagination.value.page - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let size = bytes
|
||||||
|
let unitIndex = 0
|
||||||
|
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${size.toFixed(2)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string): string => {
|
||||||
|
return new Date(dateString).toLocaleString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
refreshList
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-list {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button {
|
||||||
|
background: #27ae60;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-button:hover {
|
||||||
|
background: #219a52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status.online {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status.offline {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table th,
|
||||||
|
.file-table td {
|
||||||
|
padding: 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-table th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
font-weight: 600;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filename-cell {
|
||||||
|
max-width: 250px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.md5-cell {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
max-width: 200px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-button {
|
||||||
|
background: #e67e22;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-button:hover:not(:disabled) {
|
||||||
|
background: #d35400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-button:disabled {
|
||||||
|
background: #bdc3c7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-button {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-button:hover:not(:disabled) {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-button:disabled {
|
||||||
|
background: #bdc3c7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-info {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,169 @@
|
|||||||
|
<template>
|
||||||
|
<div class="file-upload">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref="fileInput"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
|
<button @click="triggerFileInput" class="upload-button">
|
||||||
|
选择文件上传
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- 网络状态指示器 -->
|
||||||
|
<div class="network-status" :class="isOnline ? 'online' : 'offline'">
|
||||||
|
{{ isOnline ? '在线模式' : '离线模式' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="uploading" class="upload-status">
|
||||||
|
上传中...
|
||||||
|
</div>
|
||||||
|
<div v-if="uploadResult" class="upload-result" :class="uploadResult.success ? 'success' : 'error'">
|
||||||
|
{{ uploadResult.message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import apiManager from '../utils/apiManager.ts'
|
||||||
|
|
||||||
|
interface UploadResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement>()
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadResult = ref<UploadResult | null>(null)
|
||||||
|
const isOnline = ref(false)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'fileUploaded'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 监听网络状态变化
|
||||||
|
const handleNetworkChange = (event: any) => {
|
||||||
|
isOnline.value = event.detail.isOnline
|
||||||
|
console.log('网络状态变更:', event.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isOnline.value = apiManager.isOnlineMode()
|
||||||
|
window.addEventListener('apiBaseUrlChanged', handleNetworkChange)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('apiBaseUrlChanged', handleNetworkChange)
|
||||||
|
})
|
||||||
|
|
||||||
|
const triggerFileInput = (): void => {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileSelect = async (event: Event): Promise<void> => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
await uploadFile(file)
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadFile = async (file: File): Promise<void> => {
|
||||||
|
uploading.value = true
|
||||||
|
uploadResult.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiManager.uploadFile(file)
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
uploadResult.value = {
|
||||||
|
success: true,
|
||||||
|
message: `文件 "${file.name}" 上传成功`
|
||||||
|
}
|
||||||
|
emit('fileUploaded')
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || '上传失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error)
|
||||||
|
uploadResult.value = {
|
||||||
|
success: false,
|
||||||
|
message: `上传失败: ${(error as Error).message}`
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-upload {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button:hover {
|
||||||
|
background: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status.online {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-status.offline {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-result {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-result.success {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-result.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
705
src/renderer/components/OCRPage.vue
普通文件
705
src/renderer/components/OCRPage.vue
普通文件
@ -0,0 +1,705 @@
|
|||||||
|
<!-- components/OCRPage.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="ocr-container">
|
||||||
|
<header class="ocr-header">
|
||||||
|
<button class="back-btn" @click="goBack">
|
||||||
|
← 返回文件列表
|
||||||
|
</button>
|
||||||
|
<h2>OCR 文字识别</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-primary" @click="startOcr" :disabled="isProcessing">
|
||||||
|
{{ isProcessing ? '识别中...' : '开始识别' }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" @click="exportResult" :disabled="!ocrResult">
|
||||||
|
导出结果
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="ocr-content">
|
||||||
|
<!-- 左侧文件列表 -->
|
||||||
|
<div class="file-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>源文件</h3>
|
||||||
|
<span class="file-count">共 {{ files.length }} 个文件</span>
|
||||||
|
</div>
|
||||||
|
<div class="file-list">
|
||||||
|
<div
|
||||||
|
v-for="file in files"
|
||||||
|
:key="file.id"
|
||||||
|
class="file-item"
|
||||||
|
:class="{ active: currentFile?.id === file.id }"
|
||||||
|
@click="selectFile(file)"
|
||||||
|
>
|
||||||
|
<div class="file-icon">
|
||||||
|
<img
|
||||||
|
v-if="isImage(file)"
|
||||||
|
:src="getFileThumbnail(file)"
|
||||||
|
:alt="file.originalName"
|
||||||
|
class="file-thumbnail"
|
||||||
|
/>
|
||||||
|
<div v-else class="file-type-icon" :class="getFileTypeClass(file)">
|
||||||
|
{{ getFileTypeIcon(file) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name" :title="file.originalName">
|
||||||
|
{{ file.originalName }}
|
||||||
|
</div>
|
||||||
|
<div class="file-meta">
|
||||||
|
{{ formatFileSize(file.fileSize) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 中间文件详情 -->
|
||||||
|
<div class="detail-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>文件详情</h3>
|
||||||
|
<div v-if="currentFile" class="page-info">
|
||||||
|
<span v-if="isMultiPage">第 {{ currentPage }} 页 / 共 {{ totalPages }} 页</span>
|
||||||
|
<span v-else>单页文件</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="file-preview">
|
||||||
|
<div v-if="currentFile" class="preview-content">
|
||||||
|
<!-- 图片预览 -->
|
||||||
|
<img
|
||||||
|
v-if="isImage(currentFile)"
|
||||||
|
:src="getCurrentPageImage(currentFile)"
|
||||||
|
:alt="currentFile.originalName"
|
||||||
|
class="preview-image"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 多页文档预览 -->
|
||||||
|
<div v-else-if="isMultiPage" class="document-preview">
|
||||||
|
<div class="page-controls">
|
||||||
|
<button @click="prevPage" :disabled="currentPage <= 1">上一页</button>
|
||||||
|
<span>第 {{ currentPage }} 页</span>
|
||||||
|
<button @click="nextPage" :disabled="currentPage >= totalPages">下一页</button>
|
||||||
|
</div>
|
||||||
|
<div class="page-image-container">
|
||||||
|
<img
|
||||||
|
:src="getCurrentPageImage(currentFile)"
|
||||||
|
:alt="`${currentFile.originalName} 第${currentPage}页`"
|
||||||
|
class="page-image"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 不支持预览的文件类型 -->
|
||||||
|
<div v-else class="unsupported-preview">
|
||||||
|
<div class="file-type-large" :class="getFileTypeClass(currentFile)">
|
||||||
|
{{ getFileTypeIcon(currentFile) }}
|
||||||
|
</div>
|
||||||
|
<p>该文件类型不支持预览</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-file-selected">
|
||||||
|
<p>请选择要识别的文件</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧识别结果 -->
|
||||||
|
<div class="result-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3>识别结果</h3>
|
||||||
|
<div class="result-actions">
|
||||||
|
<button class="btn btn-sm" @click="copyResult" :disabled="!ocrResult">
|
||||||
|
复制文本
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ocr-result">
|
||||||
|
<div v-if="isProcessing" class="processing">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>正在识别中,请稍候...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="ocrResult" class="result-content">
|
||||||
|
<!-- 文本内容 -->
|
||||||
|
<div
|
||||||
|
v-for="(block, index) in ocrResult.textBlocks"
|
||||||
|
:key="index"
|
||||||
|
class="text-block"
|
||||||
|
:class="block.type"
|
||||||
|
>
|
||||||
|
<div v-if="block.type === 'text'" class="text-content">
|
||||||
|
{{ block.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="block.type === 'image'" class="image-block">
|
||||||
|
<img :src="block.content" :alt="`识别出的图片 ${index + 1}`" />
|
||||||
|
<div class="image-caption">[图片]</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="block.type === 'reference'" class="reference-block">
|
||||||
|
<div class="reference-title">参考文献</div>
|
||||||
|
<div class="reference-content">{{ block.content }}</div>
|
||||||
|
<button class="search-btn" @click="searchReference(block.content)">
|
||||||
|
查找原文
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="block.type === 'citation'" class="citation-block">
|
||||||
|
<div class="citation-content">
|
||||||
|
<span class="citation-marker">[{{ block.number }}]</span>
|
||||||
|
{{ block.content }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="no-result">
|
||||||
|
<p>识别结果将显示在这里</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import apiManager from '../utils/apiManager'
|
||||||
|
|
||||||
|
interface FileRecord {
|
||||||
|
id: number
|
||||||
|
originalName: string
|
||||||
|
fileName: string
|
||||||
|
filePath: string
|
||||||
|
fileSize: number
|
||||||
|
mimeType: string
|
||||||
|
md5: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OcrResult {
|
||||||
|
textBlocks: Array<{
|
||||||
|
type: 'text' | 'image' | 'reference' | 'citation'
|
||||||
|
content: string
|
||||||
|
number?: number
|
||||||
|
confidence?: number
|
||||||
|
}>
|
||||||
|
totalPages: number
|
||||||
|
processingTime: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const files = ref<FileRecord[]>([])
|
||||||
|
const currentFile = ref<FileRecord | null>(null)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const totalPages = ref(1)
|
||||||
|
const isProcessing = ref(false)
|
||||||
|
const ocrResult = ref<OcrResult | null>(null)
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const isMultiPage = computed(() => {
|
||||||
|
if (!currentFile.value) return false
|
||||||
|
return !isImage(currentFile.value) && totalPages.value > 1
|
||||||
|
})
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
const isImage = (file: FileRecord): boolean => {
|
||||||
|
return file.mimeType.startsWith('image/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileTypeClass = (file: FileRecord): string => {
|
||||||
|
if (file.mimeType.includes('pdf')) return 'file-pdf'
|
||||||
|
if (file.mimeType.includes('word') || file.mimeType.includes('document')) return 'file-word'
|
||||||
|
if (file.mimeType.includes('excel') || file.mimeType.includes('spreadsheet')) return 'file-excel'
|
||||||
|
if (file.mimeType.includes('powerpoint') || file.mimeType.includes('presentation')) return 'file-ppt'
|
||||||
|
return 'file-other'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileTypeIcon = (file: FileRecord): string => {
|
||||||
|
if (file.mimeType.includes('pdf')) return '📄'
|
||||||
|
if (file.mimeType.includes('word') || file.mimeType.includes('document')) return '📝'
|
||||||
|
if (file.mimeType.includes('excel') || file.mimeType.includes('spreadsheet')) return '📊'
|
||||||
|
if (file.mimeType.includes('powerpoint') || file.mimeType.includes('presentation')) return '📑'
|
||||||
|
return '📎'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFileThumbnail = (file: FileRecord): string => {
|
||||||
|
return apiManager.buildUrl(`/api/files/${file.id}/thumbnail`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCurrentPageImage = (file: FileRecord): string => {
|
||||||
|
if (isMultiPage.value) {
|
||||||
|
return apiManager.buildUrl(`/api/files/${file.id}/page/${currentPage.value}`)
|
||||||
|
}
|
||||||
|
return apiManager.buildUrl(`/api/files/${file.id}/preview`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB']
|
||||||
|
let size = bytes
|
||||||
|
let unitIndex = 0
|
||||||
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
unitIndex++
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[unitIndex]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectFile = (file: FileRecord): void => {
|
||||||
|
currentFile.value = file
|
||||||
|
currentPage.value = 1
|
||||||
|
ocrResult.value = null
|
||||||
|
// 重置页面信息
|
||||||
|
if (!isImage(file)) {
|
||||||
|
// 对于文档文件,获取总页数
|
||||||
|
getDocumentPages(file)
|
||||||
|
} else {
|
||||||
|
totalPages.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDocumentPages = async (file: FileRecord): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const result = await apiManager.get<{ totalPages: number }>(`/api/files/${file.id}/pages`)
|
||||||
|
totalPages.value = result.totalPages
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取文档页数失败:', error)
|
||||||
|
totalPages.value = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevPage = (): void => {
|
||||||
|
if (currentPage.value > 1) {
|
||||||
|
currentPage.value--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPage = (): void => {
|
||||||
|
if (currentPage.value < totalPages.value) {
|
||||||
|
currentPage.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startOcr = async (): Promise<void> => {
|
||||||
|
if (!currentFile.value) {
|
||||||
|
alert('请先选择要识别的文件')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isProcessing.value = true
|
||||||
|
ocrResult.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await apiManager.post<OcrResult>('/api/ocr/recognize', {
|
||||||
|
fileId: currentFile.value.id,
|
||||||
|
page: isMultiPage.value ? currentPage.value : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
ocrResult.value = result
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OCR识别失败:', error)
|
||||||
|
alert('识别失败,请重试')
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyResult = async (): Promise<void> => {
|
||||||
|
if (!ocrResult.value) return
|
||||||
|
|
||||||
|
const text = ocrResult.value.textBlocks
|
||||||
|
.filter(block => block.type === 'text')
|
||||||
|
.map(block => block.content)
|
||||||
|
.join('\n\n')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
alert('文本已复制到剪贴板')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('复制失败:', error)
|
||||||
|
alert('复制失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportResult = (): void => {
|
||||||
|
if (!ocrResult.value) return
|
||||||
|
|
||||||
|
const content = ocrResult.value.textBlocks
|
||||||
|
.map(block => {
|
||||||
|
switch (block.type) {
|
||||||
|
case 'text':
|
||||||
|
return block.content
|
||||||
|
case 'image':
|
||||||
|
return `[图片: ${block.content}]`
|
||||||
|
case 'reference':
|
||||||
|
return `参考文献: ${block.content}`
|
||||||
|
case 'citation':
|
||||||
|
return `[${block.number}] ${block.content}`
|
||||||
|
default:
|
||||||
|
return block.content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join('\n\n')
|
||||||
|
|
||||||
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `${currentFile.value?.originalName || 'ocr_result'}.txt`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchReference = (reference: string): void => {
|
||||||
|
// 打开新的浏览器标签页搜索参考文献
|
||||||
|
const searchUrl = `https://scholar.google.com/scholar?q=${encodeURIComponent(reference)}`
|
||||||
|
window.open(searchUrl, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = (): void => {
|
||||||
|
router.back()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// 获取所有文件列表
|
||||||
|
const fileList = await apiManager.get<FileRecord[]>('/api/files')
|
||||||
|
files.value = fileList
|
||||||
|
|
||||||
|
// 如果URL中有fileId参数,自动选择该文件
|
||||||
|
const fileId = route.query.fileId as string
|
||||||
|
if (fileId) {
|
||||||
|
const file = files.value.find(f => f.id.toString() === fileId)
|
||||||
|
if (file) {
|
||||||
|
selectFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取文件列表失败:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ocr-container {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-header {
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #6c757d;
|
||||||
|
color: #6c757d;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-content {
|
||||||
|
flex: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 300px 1fr 400px;
|
||||||
|
gap: 1px;
|
||||||
|
background: #e9ecef;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-panel,
|
||||||
|
.detail-panel,
|
||||||
|
.result-panel {
|
||||||
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-count {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 1px solid #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.active {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-left: 3px solid #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-pdf { color: #f44336; }
|
||||||
|
.file-word { color: #2196f3; }
|
||||||
|
.file-excel { color: #4caf50; }
|
||||||
|
.file-ppt { color: #ff9800; }
|
||||||
|
.file-other { color: #9e9e9e; }
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-preview {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image,
|
||||||
|
.page-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-image-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-type-large {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsupported-preview,
|
||||||
|
.no-file-selected {
|
||||||
|
text-align: center;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-result {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.processing {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
border: 3px solid #f3f3f3;
|
||||||
|
border-top: 3px solid #007bff;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-content {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-block {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-block {
|
||||||
|
text-align: center;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-block img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 200px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-caption {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reference-block {
|
||||||
|
background: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reference-title {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-btn {
|
||||||
|
background: #856404;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-block {
|
||||||
|
background: #e7f3ff;
|
||||||
|
border-left: 3px solid #2196f3;
|
||||||
|
padding: 0.5rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.citation-marker {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2196f3;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-result {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
37
src/renderer/index.html
普通文件
37
src/renderer/index.html
普通文件
@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
|
||||||
|
<title>Electron Vue 客户端</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Microsoft YaHei', sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div class="loading">应用加载中...</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
src/renderer/main.ts
普通文件
9
src/renderer/main.ts
普通文件
@ -0,0 +1,9 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
import { router } from './router';
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
app.mount('#app')
|
||||||
23
src/renderer/router/index.ts
普通文件
23
src/renderer/router/index.ts
普通文件
@ -0,0 +1,23 @@
|
|||||||
|
// router/index.ts
|
||||||
|
import {createRouter, createWebHashHistory} from 'vue-router'
|
||||||
|
import OCRPage from '../components/OCRPage.vue'
|
||||||
|
import HomeView from '../views/Home.vue'
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: HomeView
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/ocr',
|
||||||
|
name: 'OCR',
|
||||||
|
component: OCRPage
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
export const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes
|
||||||
|
});
|
||||||
5
src/renderer/shims-vue.d.ts
vendored
普通文件
5
src/renderer/shims-vue.d.ts
vendored
普通文件
@ -0,0 +1,5 @@
|
|||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
38
src/renderer/style.css
普通文件
38
src/renderer/style.css
普通文件
@ -0,0 +1,38 @@
|
|||||||
|
/* 全局样式重置 */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
228
src/renderer/utils/apiManager.ts
普通文件
228
src/renderer/utils/apiManager.ts
普通文件
@ -0,0 +1,228 @@
|
|||||||
|
// utils/apimanager.ts
|
||||||
|
|
||||||
|
// API 响应类型定义
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
data?: T
|
||||||
|
status: string
|
||||||
|
message?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResponse {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
url?: string
|
||||||
|
fileId?: string
|
||||||
|
path?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestOptions extends RequestInit {
|
||||||
|
headers?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Electron API 类型定义
|
||||||
|
interface ElectronAPI {
|
||||||
|
getApiBaseUrl: () => Promise<string>
|
||||||
|
onApiBaseUrlChange: (callback: (newUrl: string) => void) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扩展 Window 接口
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electronAPI?: ElectronAPI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 地址变更事件详情
|
||||||
|
export interface ApiBaseUrlChangeDetail {
|
||||||
|
baseUrl: string
|
||||||
|
isOnline: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiManager {
|
||||||
|
public baseUrl: string
|
||||||
|
public isOnline: boolean
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.baseUrl = 'http://localhost:3000'
|
||||||
|
this.isOnline = false
|
||||||
|
this.init()
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(): Promise<void> {
|
||||||
|
// 从主进程获取初始API地址
|
||||||
|
if (window.electronAPI) {
|
||||||
|
try {
|
||||||
|
this.baseUrl = await window.electronAPI.getApiBaseUrl()
|
||||||
|
this.isOnline = !this.baseUrl.includes('localhost')
|
||||||
|
|
||||||
|
// 监听API地址变化
|
||||||
|
window.electronAPI.onApiBaseUrlChange((newUrl: string) => {
|
||||||
|
console.log('API地址变更:', newUrl)
|
||||||
|
this.baseUrl = newUrl
|
||||||
|
this.isOnline = !newUrl.includes('localhost')
|
||||||
|
|
||||||
|
// 触发事件通知其他组件
|
||||||
|
window.dispatchEvent(new CustomEvent<ApiBaseUrlChangeDetail>('apiBaseUrlChanged', {
|
||||||
|
detail: { baseUrl: newUrl, isOnline: this.isOnline }
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取API基础地址失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('API管理器初始化完成,当前服务:', this.baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
getBaseUrl(): string {
|
||||||
|
return this.baseUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
isOnlineMode(): boolean {
|
||||||
|
return this.isOnline
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建完整的API URL
|
||||||
|
buildUrl(endpoint: string): string {
|
||||||
|
// 如果已经是完整URL,直接返回
|
||||||
|
if (endpoint.startsWith('http')) {
|
||||||
|
return endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保endpoint以/开头
|
||||||
|
if (!endpoint.startsWith('/')) {
|
||||||
|
endpoint = '/' + endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${this.baseUrl}${endpoint}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统一的请求方法
|
||||||
|
async request<T = any>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const url = this.buildUrl(endpoint)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
},
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json() as T
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`API请求失败 [${url}]:`, error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET 请求
|
||||||
|
async get<T = any>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST 请求
|
||||||
|
async post<T = any>(endpoint: string, data?: any, options: RequestOptions = {}): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT 请求
|
||||||
|
async put<T = any>(endpoint: string, data?: any, options: RequestOptions = {}): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE 请求
|
||||||
|
async delete<T = any>(endpoint: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, {
|
||||||
|
method: 'DELETE',
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH 请求
|
||||||
|
async patch<T = any>(endpoint: string, data?: any, options: RequestOptions = {}): Promise<T> {
|
||||||
|
return this.request<T>(endpoint, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: data ? JSON.stringify(data) : undefined,
|
||||||
|
...options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上传文件
|
||||||
|
async uploadFile(file: File): Promise<UploadResponse> {
|
||||||
|
const url = this.buildUrl('/api/upload')
|
||||||
|
const formData = new FormData()
|
||||||
|
|
||||||
|
// 编码文件名处理中文
|
||||||
|
const encodedFile = new File([file], encodeURIComponent(file.name), {
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified
|
||||||
|
})
|
||||||
|
|
||||||
|
formData.append('file', encodedFile)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Upload failed with status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json() as UploadResponse
|
||||||
|
} catch (error) {
|
||||||
|
console.error('文件上传失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载文件
|
||||||
|
async downloadFile(endpoint: string, filename?: string): Promise<void> {
|
||||||
|
const url = this.buildUrl(endpoint)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Download failed with status: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob()
|
||||||
|
const downloadUrl = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = downloadUrl
|
||||||
|
link.download = filename || 'download'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(downloadUrl)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('文件下载失败:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建全局API管理器实例
|
||||||
|
export const apiManager = new ApiManager()
|
||||||
|
|
||||||
|
export default apiManager
|
||||||
148
src/renderer/views/Home.vue
普通文件
148
src/renderer/views/Home.vue
普通文件
@ -0,0 +1,148 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<header class="app-header">
|
||||||
|
<h1>文件管理客户端</h1>
|
||||||
|
<div class="network-indicator" :class="isOnline ? 'online' : 'offline'">
|
||||||
|
{{ isOnline ? '在线模式' : '离线模式' }}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="app-main">
|
||||||
|
<FileUpload @file-uploaded="refreshFileList" />
|
||||||
|
<FileList
|
||||||
|
ref="fileListRef"
|
||||||
|
@md5-check="handleMD5Check"
|
||||||
|
@ocr-recognize="handleOcrRecognize"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import FileUpload from '../components/FileUpload.vue'
|
||||||
|
import FileList from '../components/FileList.vue'
|
||||||
|
import apiManager from '../utils/apiManager'
|
||||||
|
|
||||||
|
interface FileRecord {
|
||||||
|
id: number
|
||||||
|
originalName: string
|
||||||
|
fileName: string
|
||||||
|
filePath: string
|
||||||
|
fileSize: number
|
||||||
|
mimeType: string
|
||||||
|
md5: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const fileListRef = ref<InstanceType<typeof FileList>>()
|
||||||
|
const isOnline = ref(false)
|
||||||
|
|
||||||
|
// 监听网络状态变化
|
||||||
|
const handleNetworkChange = (event: CustomEvent<{ baseUrl: string; isOnline: boolean }>) => {
|
||||||
|
isOnline.value = event.detail.isOnline
|
||||||
|
console.log('App: 网络状态变更', event.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isOnline.value = apiManager.isOnlineMode()
|
||||||
|
window.addEventListener('apiBaseUrlChanged', handleNetworkChange as EventListener)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('apiBaseUrlChanged', handleNetworkChange as EventListener)
|
||||||
|
})
|
||||||
|
|
||||||
|
const refreshFileList = (): void => {
|
||||||
|
fileListRef.value?.refreshList()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMD5Check = async (file: FileRecord, isChanged: boolean, newMD5: string): Promise<void> => {
|
||||||
|
if (isChanged) {
|
||||||
|
const confirmed = confirm('文件内容已发生变化,是否更新MD5信息?')
|
||||||
|
if (confirmed) {
|
||||||
|
try {
|
||||||
|
await apiManager.request(`/api/files/${file.id}/update-md5`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ md5: newMD5 })
|
||||||
|
})
|
||||||
|
|
||||||
|
alert('MD5信息已更新')
|
||||||
|
refreshFileList()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新MD5失败:', error)
|
||||||
|
alert('更新失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert('文件未发生变化')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOcrRecognize = (file: FileRecord): void => {
|
||||||
|
// 跳转到OCR识别页面
|
||||||
|
router.push(`/ocr?fileId=${file.id}`).then((res) => {
|
||||||
|
console.log('导航成功:', res)
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('导航错误:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-indicator {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-indicator.online {
|
||||||
|
background: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.network-indicator.offline {
|
||||||
|
background: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
16
tsconfig.app.json
普通文件
16
tsconfig.app.json
普通文件
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
20
tsconfig.json
普通文件
20
tsconfig.json
普通文件
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"]
|
||||||
|
}
|
||||||
26
tsconfig.node.json
普通文件
26
tsconfig.node.json
普通文件
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
24
vite.config.ts
普通文件
24
vite.config.ts
普通文件
@ -0,0 +1,24 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
root: resolve(__dirname, 'src/renderer'),
|
||||||
|
base: './',
|
||||||
|
build: {
|
||||||
|
outDir: resolve(__dirname, 'dist'),
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
external: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src/renderer')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
正在加载...
在新工单中引用
屏蔽一个用户