这个提交包含在:
徐勤民 2025-11-11 15:27:04 +08:00
当前提交 9e8571402d
共有 26 个文件被更改,包括 5195 次插入0 次删除

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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件

二进制文件未显示。

之后

宽度:  |  高度:  |  大小: 15 KiB

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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -0,0 +1,5 @@
<template>
<router-view />
</template>
<script setup lang="ts">
</script>

查看文件

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

查看文件

@ -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
// URLfileId
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 普通文件
查看文件

@ -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 普通文件
查看文件

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

查看文件

@ -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 普通文件
查看文件

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

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;
}

查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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 普通文件
查看文件

@ -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')
}
}
})

2846
yarn.lock 普通文件

文件差异内容过多而无法显示 加载差异