- 实时图传:WebSocket JPEG 帧发送 + 帧率控制 + PC 浏览器预览 - PDF 上传与处理:上传/处理分离,支持 ocrpdf 和 markdown 两种类型 - MinerU 真实接入:markdown 处理 + images ZIP 打包 - OCRmyPDF 接入:ocrpdf 生成可搜索双层 PDF - 手机端任务管理面板:轮询状态 + SAF 目录选择下载 - PC 管理面板:/dashboard 文件与任务管理 - 网络层:OkHttp 客户端、WebSocket 图传、局域网发现占位 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
3
.idea/.gitignore
generated
vendored
@@ -1,3 +0,0 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
6
.idea/AndroidProjectSystem.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AndroidProjectSystem">
|
||||
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/compiler.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="11" />
|
||||
</component>
|
||||
</project>
|
||||
21
.idea/gradle.xml
generated
@@ -1,21 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleMigrationSettings" migrationVersion="1" />
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="testRunner" value="CHOOSE_PER_TEST" />
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
<option value="$PROJECT_DIR$/app" />
|
||||
<option value="$PROJECT_DIR$/evaluation" />
|
||||
<option value="$PROJECT_DIR$/imageprocessing" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
61
.idea/inspectionProfiles/Project_Default.xml
generated
@@ -1,61 +0,0 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
|
||||
<option name="composableFile" value="true" />
|
||||
<option name="previewFile" value="true" />
|
||||
</inspection_tool>
|
||||
</profile>
|
||||
</component>
|
||||
10
.idea/migrations.xml
generated
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectMigrations">
|
||||
<option name="MigrateToGradleLocalJavaHome">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
9
.idea/misc.xml
generated
@@ -1,9 +0,0 @@
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
|
||||
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||
</component>
|
||||
<component name="ProjectType">
|
||||
<option name="id" value="Android" />
|
||||
</component>
|
||||
</project>
|
||||
17
.idea/runConfigurations.xml
generated
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="RunConfigurationProducerService">
|
||||
<option name="ignoredProducers">
|
||||
<set>
|
||||
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
|
||||
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
|
||||
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
|
||||
</set>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -136,6 +136,7 @@ dependencies {
|
||||
implementation(libs.reorderable)
|
||||
implementation(libs.aboutlibraries.compose.m3)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.okhttp)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.assertj)
|
||||
|
||||
@@ -8,9 +8,10 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<!-- Required (on Android 9 and lower) to save files -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28"/>
|
||||
<!-- REMOVE ACCESS_NETWORK_STATE -->
|
||||
<!-- cameraX 1.6.1 depends on androidx.media3:media3-common which adds ACCESS_NETWORK_STATE -->
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
|
||||
<!-- Network permissions for LAN communication -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -20,9 +21,11 @@
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:name=".FairScanApp"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.FairScan"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="tiramisu">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
|
||||
@@ -29,7 +29,14 @@ import org.fairscan.app.platform.AndroidPdfWriter
|
||||
import org.fairscan.app.ui.screens.camera.CameraViewModel
|
||||
import org.fairscan.app.ui.screens.settings.SettingsRepository
|
||||
import org.fairscan.app.ui.screens.settings.SettingsViewModel
|
||||
import okhttp3.OkHttpClient
|
||||
import org.fairscan.app.network.NetworkInfoProvider
|
||||
import org.fairscan.app.network.stream.OkHttpStreamClient
|
||||
import org.fairscan.app.network.stream.StreamClient
|
||||
import org.fairscan.app.network.tasks.TaskClient
|
||||
import org.fairscan.app.network.upload.PdfUploadClient
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FairScanApp : Application() {
|
||||
lateinit var appContainer: AppContainer
|
||||
@@ -57,6 +64,17 @@ class AppContainer(context: Context) {
|
||||
val imageLoader = AndroidImageLoader(context.contentResolver)
|
||||
val settingsRepository = SettingsRepository(context)
|
||||
|
||||
// Network modules
|
||||
val networkInfoProvider = NetworkInfoProvider()
|
||||
val okHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.SECONDS) // No read timeout for streaming
|
||||
.writeTimeout(0, TimeUnit.SECONDS) // No write timeout
|
||||
.build()
|
||||
val streamClient: StreamClient = OkHttpStreamClient(okHttpClient)
|
||||
val pdfUploadClient = PdfUploadClient(okHttpClient)
|
||||
val taskClient = TaskClient(okHttpClient)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
inline fun <reified VM : ViewModel> viewModelFactory(
|
||||
crossinline create: (AppContainer) -> VM
|
||||
|
||||
@@ -126,6 +126,7 @@ class MainActivity : ComponentActivity() {
|
||||
val documentUiState by viewModel.documentUiState.collectAsStateWithLifecycle()
|
||||
val cropInitialState by viewModel.cropInitState.collectAsStateWithLifecycle()
|
||||
val exportUiState by exportViewModel.uiState.collectAsStateWithLifecycle()
|
||||
val taskPanelState by exportViewModel.taskPanelState.collectAsStateWithLifecycle()
|
||||
val cameraPermission = rememberCameraPermissionState()
|
||||
CollectCameraEvents(cameraViewModel, viewModel)
|
||||
CollectExportEvents(context, exportViewModel)
|
||||
@@ -211,7 +212,14 @@ class MainActivity : ComponentActivity() {
|
||||
share = { exportViewModel.onShareClicked() },
|
||||
save = { exportViewModel.onSaveClicked() },
|
||||
open = { item -> openUri(item.uri, item.format.mimeType, logger) },
|
||||
uploadToPc = { exportViewModel.uploadPdfToServer() },
|
||||
uploadAndProcess = { processType -> exportViewModel.uploadAndProcess(processType) },
|
||||
downloadResult = { task, destDirUri, context ->
|
||||
exportViewModel.downloadResult(task, destDirUri, context)
|
||||
},
|
||||
resetDownloadState = { exportViewModel.resetDownloadState() },
|
||||
),
|
||||
taskPanelState = taskPanelState,
|
||||
onCloseScan = {
|
||||
exportViewModel.resetFilename()
|
||||
viewModel.startNewDocument()
|
||||
@@ -295,6 +303,14 @@ class MainActivity : ComponentActivity() {
|
||||
onResetExportDirClick = { settingsViewModel.setExportDirUri(null) },
|
||||
onExportFormatChanged = { format -> settingsViewModel.setExportFormat(format) },
|
||||
onExportQualityChanged = { quality -> settingsViewModel.setExportQuality(quality) },
|
||||
onServerHostChanged = { host -> settingsViewModel.setServerHost(host) },
|
||||
onServerPortChanged = { port -> settingsViewModel.setServerPort(port) },
|
||||
onStreamQualityChanged = { quality -> settingsViewModel.setStreamQuality(quality) },
|
||||
onPostProcessModeChanged = { mode -> settingsViewModel.setPostProcessMode(mode) },
|
||||
onAutoDownloadChanged = { enabled -> settingsViewModel.setAutoDownloadProcessedResult(enabled) },
|
||||
onStreamFrameRateChanged = { rate -> settingsViewModel.setStreamFrameRate(rate) },
|
||||
onScanNetworkHostsClick = { /* TODO: Implement network discovery */ },
|
||||
onTestConnectionClick = { /* TODO: Implement connection test */ },
|
||||
onBack = nav.back,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network
|
||||
|
||||
import java.net.Inet4Address
|
||||
import java.net.NetworkInterface
|
||||
|
||||
class NetworkInfoProvider {
|
||||
|
||||
fun getLocalIpAddress(): String? {
|
||||
return try {
|
||||
NetworkInterface.getNetworkInterfaces().asSequence()
|
||||
.flatMap { it.inetAddresses.asSequence() }
|
||||
.filterNot { it.isLoopbackAddress }
|
||||
.filterIsInstance<Inet4Address>()
|
||||
.firstOrNull()
|
||||
?.hostAddress
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
24
app/src/main/java/org/fairscan/app/network/ServerEndpoint.kt
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network
|
||||
|
||||
data class ServerEndpoint(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val protocol: String = "http",
|
||||
) {
|
||||
val url: String get() = "$protocol://$host:$port"
|
||||
val wsUrl: String get() = "ws://$host:$port"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network.discovery
|
||||
|
||||
data class DiscoveredHost(
|
||||
val serviceName: String,
|
||||
val displayName: String,
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val features: List<String> = emptyList(),
|
||||
val version: String? = null,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network.discovery
|
||||
|
||||
sealed class DiscoveryState {
|
||||
data object Idle : DiscoveryState()
|
||||
data object Discovering : DiscoveryState()
|
||||
data class Success(val hosts: List<DiscoveredHost>) : DiscoveryState()
|
||||
data object Empty : DiscoveryState()
|
||||
data class Error(val message: String) : DiscoveryState()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network.discovery
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface LanServiceDiscovery {
|
||||
suspend fun startDiscovery(serviceType: String): Flow<DiscoveryState>
|
||||
suspend fun stopDiscovery()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network.stream
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class FrameCompressor {
|
||||
|
||||
/**
|
||||
* Compress a bitmap to JPEG bytes with resize and quality settings.
|
||||
* Returns null if compression fails.
|
||||
*/
|
||||
fun compress(
|
||||
source: Bitmap,
|
||||
maxDimension: Int,
|
||||
jpegQuality: Int,
|
||||
): ByteArray? {
|
||||
return try {
|
||||
val resized = resizeIfNeeded(source, maxDimension)
|
||||
val output = ByteArrayOutputStream()
|
||||
resized.compress(Bitmap.CompressFormat.JPEG, jpegQuality, output)
|
||||
output.toByteArray()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun resizeIfNeeded(bitmap: Bitmap, maxDimension: Int): Bitmap {
|
||||
val width = bitmap.width
|
||||
val height = bitmap.height
|
||||
val max = maxOf(width, height)
|
||||
if (max <= maxDimension) return bitmap
|
||||
|
||||
val ratio = maxDimension.toFloat() / max
|
||||
val newWidth = (width * ratio).toInt()
|
||||
val newHeight = (height * ratio).toInt()
|
||||
return Bitmap.createScaledBitmap(bitmap, newWidth, newHeight, true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network.stream
|
||||
|
||||
/**
|
||||
* Controls frame dropping based on minimum interval between sends.
|
||||
* If a frame arrives before the minimum interval has elapsed, it is dropped.
|
||||
*/
|
||||
class FrameDropController {
|
||||
@Volatile
|
||||
private var lastSendTimeMs: Long = 0L
|
||||
|
||||
private val isSending = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
|
||||
/**
|
||||
* Returns true if this frame should be dropped.
|
||||
* @param minIntervalMs Minimum interval between frames in ms.
|
||||
* If <= 0, no time-based dropping (only isSending guard).
|
||||
*/
|
||||
fun shouldDrop(minIntervalMs: Long): Boolean {
|
||||
if (isSending.get()) return true
|
||||
// Unlimited mode: no time-based dropping
|
||||
if (minIntervalMs <= 0) return false
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastSendTimeMs < minIntervalMs) return true
|
||||
return false
|
||||
}
|
||||
|
||||
fun onFrameSent() {
|
||||
lastSendTimeMs = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
fun markSending(value: Boolean) {
|
||||
isSending.set(value)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
lastSendTimeMs = 0L
|
||||
isSending.set(false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network.stream
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import okhttp3.OkHttpClient
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import org.fairscan.app.network.ServerEndpoint
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class OkHttpStreamClient(
|
||||
private val okHttpClient: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(5, TimeUnit.SECONDS)
|
||||
.readTimeout(0, TimeUnit.SECONDS) // No read timeout for streaming
|
||||
.writeTimeout(0, TimeUnit.SECONDS) // No write timeout for streaming
|
||||
.build(),
|
||||
) : StreamClient {
|
||||
|
||||
private val _state = MutableStateFlow<StreamState>(StreamState.Disconnected)
|
||||
override val state: StateFlow<StreamState> = _state.asStateFlow()
|
||||
|
||||
private var webSocket: WebSocket? = null
|
||||
|
||||
override suspend fun connect(endpoint: ServerEndpoint) {
|
||||
if (_state.value is StreamState.Connected || _state.value is StreamState.Connecting) return
|
||||
|
||||
_state.value = StreamState.Connecting
|
||||
val request = Request.Builder()
|
||||
.url("ws://${endpoint.host}:${endpoint.port}/stream")
|
||||
.build()
|
||||
|
||||
webSocket = okHttpClient.newWebSocket(request, object : WebSocketListener() {
|
||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
||||
_state.value = StreamState.Connected
|
||||
}
|
||||
|
||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
||||
_state.value = StreamState.Error(t.message ?: "Connection failed")
|
||||
}
|
||||
|
||||
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
||||
_state.value = StreamState.Disconnected
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
webSocket.close(1000, "Client closing")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun sendFrame(frameData: ByteArray): Boolean {
|
||||
val ws = webSocket ?: return false
|
||||
return ws.send(frameData.toByteString())
|
||||
}
|
||||
|
||||
override suspend fun disconnect() {
|
||||
webSocket?.close(1000, "Client disconnect")
|
||||
webSocket = null
|
||||
_state.value = StreamState.Disconnected
|
||||
}
|
||||
}
|
||||
|
||||
interface StreamClient {
|
||||
val state: StateFlow<StreamState>
|
||||
suspend fun connect(endpoint: ServerEndpoint)
|
||||
fun sendFrame(frameData: ByteArray): Boolean
|
||||
suspend fun disconnect()
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network.stream
|
||||
|
||||
import org.fairscan.app.ui.screens.settings.StreamQuality
|
||||
|
||||
data class StreamQualityPreset(
|
||||
val label: String,
|
||||
val maxResolution: Int,
|
||||
val jpegQuality: Int,
|
||||
val targetFps: Int,
|
||||
) {
|
||||
val minIntervalMs: Long get() = (1000L / targetFps).coerceAtLeast(50L)
|
||||
}
|
||||
|
||||
fun StreamQuality.toPreset(): StreamQualityPreset = when (this) {
|
||||
StreamQuality.LOW -> StreamQualityPreset("Low", 640, 45, 10)
|
||||
StreamQuality.BALANCED -> StreamQualityPreset("Balanced", 960, 60, 8)
|
||||
StreamQuality.HIGH -> StreamQualityPreset("High", 1280, 75, 6)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network.stream
|
||||
|
||||
sealed class StreamState {
|
||||
data object Disconnected : StreamState()
|
||||
data object Connecting : StreamState()
|
||||
data object Connected : StreamState()
|
||||
data class Error(val message: String) : StreamState()
|
||||
}
|
||||
188
app/src/main/java/org/fairscan/app/network/tasks/TaskClient.kt
Normal file
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network.tasks
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.fairscan.app.network.ServerEndpoint
|
||||
import org.fairscan.app.network.upload.PdfUploadClient
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Client for task management operations on the FairScan PC server.
|
||||
*/
|
||||
class TaskClient(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val downloadClient = okHttpClient.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(120, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Create a processing task for an uploaded PDF.
|
||||
*
|
||||
* @param endpoint Server endpoint.
|
||||
* @param fileName Name of the uploaded PDF file.
|
||||
* @param mode Processing mode (e.g., "OCRPdf" or "Markdown").
|
||||
* @return ProcessTaskResult with the assigned task ID.
|
||||
*/
|
||||
fun processPdf(
|
||||
endpoint: ServerEndpoint,
|
||||
fileId: String,
|
||||
processType: String = "ocrpdf",
|
||||
): ProcessTaskResult {
|
||||
val url = "${endpoint.url}/tasks/process"
|
||||
val json = """{"fileId":"$fileId","processType":"$processType"}"""
|
||||
val requestBody = json.toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
val body = response.body?.string() ?: throw IOException("Empty response")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Failed to create task (${response.code}): $body")
|
||||
}
|
||||
|
||||
val taskId = PdfUploadClient.extractJsonString(body, "taskId") ?: ""
|
||||
val status = PdfUploadClient.extractJsonString(body, "status") ?: "unknown"
|
||||
return ProcessTaskResult(taskId, status, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current status of a processing task.
|
||||
*/
|
||||
fun getTaskStatus(endpoint: ServerEndpoint, taskId: String): TaskStatus {
|
||||
val url = "${endpoint.url}/tasks/$taskId"
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
val body = response.body?.string() ?: throw IOException("Empty response")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Failed to get task status (${response.code}): $body")
|
||||
}
|
||||
|
||||
return TaskStatus(
|
||||
taskId = PdfUploadClient.extractJsonString(body, "taskId") ?: taskId,
|
||||
status = PdfUploadClient.extractJsonString(body, "status") ?: "unknown",
|
||||
progress = extractJsonInt(body, "progress") ?: 0,
|
||||
fileName = PdfUploadClient.extractJsonString(body, "fileName") ?: "",
|
||||
createdAt = PdfUploadClient.extractJsonString(body, "createdAt") ?: "",
|
||||
message = PdfUploadClient.extractJsonString(body, "message") ?: "",
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* List artifacts (result files) for a completed task.
|
||||
*/
|
||||
fun listArtifacts(endpoint: ServerEndpoint, taskId: String): List<ArtifactInfo> {
|
||||
val url = "${endpoint.url}/tasks/$taskId/artifacts"
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
val response = okHttpClient.newCall(request).execute()
|
||||
val body = response.body?.string() ?: throw IOException("Empty response")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Failed to list artifacts (${response.code}): $body")
|
||||
}
|
||||
|
||||
return parseArtifactList(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an artifact to a destination file.
|
||||
*
|
||||
* @return The destination file (same as [destFile]).
|
||||
*/
|
||||
fun downloadArtifact(
|
||||
endpoint: ServerEndpoint,
|
||||
artifactId: String,
|
||||
destFile: File,
|
||||
onProgress: ((Float) -> Unit)? = null,
|
||||
): File {
|
||||
val url = "${endpoint.url}/artifacts/$artifactId/download"
|
||||
val request = Request.Builder().url(url).get().build()
|
||||
|
||||
val response = downloadClient.newCall(request).execute()
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Failed to download artifact (${response.code})")
|
||||
}
|
||||
|
||||
val body = response.body ?: throw IOException("Empty response body")
|
||||
|
||||
destFile.parentFile?.mkdirs()
|
||||
val total = body.contentLength()
|
||||
var bytesRead = 0L
|
||||
|
||||
body.byteStream().use { input ->
|
||||
FileOutputStream(destFile).use { output ->
|
||||
val buffer = ByteArray(8192)
|
||||
var read: Int
|
||||
while (input.read(buffer).also { read = it } != -1) {
|
||||
output.write(buffer, 0, read)
|
||||
bytesRead += read
|
||||
if (onProgress != null && total > 0) {
|
||||
onProgress(bytesRead.toFloat() / total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return destFile
|
||||
}
|
||||
|
||||
private fun parseArtifactList(json: String): List<ArtifactInfo> {
|
||||
val artifacts = mutableListOf<ArtifactInfo>()
|
||||
var pos = json.indexOf('[')
|
||||
if (pos < 0) return artifacts
|
||||
|
||||
pos = json.indexOf('{', pos)
|
||||
while (pos >= 0) {
|
||||
val end = json.indexOf('}', pos)
|
||||
if (end < 0) break
|
||||
val obj = json.substring(pos, end + 1)
|
||||
val id = PdfUploadClient.extractJsonString(obj, "id")
|
||||
?: PdfUploadClient.extractJsonString(obj, "artifactId")
|
||||
val fileName = PdfUploadClient.extractJsonString(obj, "fileName") ?: ""
|
||||
val fileSize = extractJsonLong(obj, "fileSize") ?: 0L
|
||||
val fileType = PdfUploadClient.extractJsonString(obj, "fileType") ?: ""
|
||||
if (id != null) {
|
||||
artifacts.add(ArtifactInfo(id, fileName, fileSize, fileType))
|
||||
}
|
||||
pos = json.indexOf('{', end + 1)
|
||||
}
|
||||
return artifacts
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun extractJsonInt(json: String, key: String): Int? {
|
||||
val pattern = "\"$key\"\\s*:\\s*(\\d+)".toRegex()
|
||||
return pattern.find(json)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
}
|
||||
|
||||
fun extractJsonLong(json: String, key: String): Long? {
|
||||
val pattern = "\"$key\"\\s*:\\s*(\\d+)".toRegex()
|
||||
return pattern.find(json)?.groupValues?.getOrNull(1)?.toLongOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network.tasks
|
||||
|
||||
/**
|
||||
* Status of a processing task on the PC server.
|
||||
*/
|
||||
data class TaskStatus(
|
||||
val taskId: String,
|
||||
val status: String, // queued, processing, completed, failed
|
||||
val progress: Int = 0, // 0-100
|
||||
val fileName: String = "",
|
||||
val createdAt: String = "",
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
* Information about a processed artifact (result file) on the PC server.
|
||||
*/
|
||||
data class ArtifactInfo(
|
||||
val artifactId: String,
|
||||
val fileName: String,
|
||||
val fileSize: Long = 0,
|
||||
val fileType: String = "", // "pdf", "markdown", "md", etc.
|
||||
)
|
||||
|
||||
/**
|
||||
* Result of creating a processing task.
|
||||
*/
|
||||
data class ProcessTaskResult(
|
||||
val taskId: String,
|
||||
val status: String,
|
||||
val message: String = "",
|
||||
)
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2025-2026 The FairScan authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify it
|
||||
* under the terms of the GNU General Public License as published by the Free
|
||||
* Software Foundation, either version 3 of the License, or (at your option)
|
||||
* any later version.
|
||||
* This program is distributed in the hope that it will be useful, but WITHOUT
|
||||
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
||||
* FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
||||
* more details.
|
||||
* You should have received a copy of the GNU General Public License along with
|
||||
* this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.fairscan.app.network.upload
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import org.fairscan.app.network.ServerEndpoint
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Result of a PDF upload operation.
|
||||
*/
|
||||
data class UploadResult(
|
||||
val fileId: String,
|
||||
val fileName: String = "",
|
||||
val sizeBytes: Long = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Client for uploading PDF files to the FairScan PC server.
|
||||
*/
|
||||
class PdfUploadClient(
|
||||
private val okHttpClient: OkHttpClient,
|
||||
) {
|
||||
private val uploadTimeoutClient = okHttpClient.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(60, TimeUnit.SECONDS)
|
||||
.writeTimeout(120, TimeUnit.SECONDS) // Large files need time to upload
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Upload a PDF file to the PC server.
|
||||
*
|
||||
* @param endpoint The server endpoint to upload to.
|
||||
* @param file The PDF file to upload.
|
||||
* @param onProgress Callback with progress 0.0..1.0 (approximate, based on bytes written).
|
||||
* @return UploadResult with the task ID assigned by the server.
|
||||
* @throws IOException on network or server error.
|
||||
*/
|
||||
fun uploadPdf(
|
||||
endpoint: ServerEndpoint,
|
||||
file: File,
|
||||
onProgress: ((Float) -> Unit)? = null,
|
||||
): UploadResult {
|
||||
val url = "${endpoint.url}/upload/pdf"
|
||||
|
||||
val fileBody = object : RequestBody() {
|
||||
override fun contentType() = "application/pdf".toMediaType()
|
||||
override fun contentLength() = file.length()
|
||||
|
||||
override fun writeTo(sink: okio.BufferedSink) {
|
||||
val buffer = ByteArray(8192)
|
||||
val total = file.length()
|
||||
var written = 0L
|
||||
file.inputStream().use { input ->
|
||||
var bytesRead: Int
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
sink.write(buffer, 0, bytesRead)
|
||||
written += bytesRead
|
||||
if (onProgress != null && total > 0) {
|
||||
onProgress(written.toFloat() / total)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("file", file.name, fileBody)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = uploadTimeoutClient.newCall(request).execute()
|
||||
val responseBody = response.body?.string() ?: throw IOException("Empty response from server")
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Upload failed (${response.code}): $responseBody")
|
||||
}
|
||||
|
||||
// Parse JSON response — simple manual parse to avoid adding a JSON library
|
||||
return parseUploadResponse(responseBody)
|
||||
}
|
||||
|
||||
private fun parseUploadResponse(json: String): UploadResult {
|
||||
val fileId = extractJsonString(json, "fileId") ?: ""
|
||||
val fileName = extractJsonString(json, "fileName") ?: ""
|
||||
val sizeBytes = extractJsonLong(json, "sizeBytes") ?: 0L
|
||||
return UploadResult(fileId, fileName, sizeBytes)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun extractJsonString(json: String, key: String): String? {
|
||||
val pattern = "\"$key\"\\s*:\\s*\"([^\"]*)\"".toRegex()
|
||||
return pattern.find(json)?.groupValues?.getOrNull(1)
|
||||
}
|
||||
|
||||
fun extractJsonLong(json: String, key: String): Long? {
|
||||
val pattern = "\"$key\"\\s*:\\s*(\\d+)".toRegex()
|
||||
return pattern.find(json)?.groupValues?.getOrNull(1)?.toLongOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AddPhotoAlternate
|
||||
import androidx.compose.material.icons.filled.Cast
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Highlight
|
||||
import androidx.compose.material3.Button
|
||||
@@ -104,6 +105,7 @@ import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.domain.Jpeg
|
||||
import org.fairscan.app.domain.PageMetadata
|
||||
import org.fairscan.app.domain.Rotation.R0
|
||||
import org.fairscan.app.network.stream.StreamState
|
||||
import org.fairscan.app.ui.Navigation
|
||||
import org.fairscan.app.ui.Screen
|
||||
import org.fairscan.app.ui.components.CameraPermissionState
|
||||
@@ -140,6 +142,10 @@ fun CameraScreen(
|
||||
val isTorchEnabled by cameraViewModel.isTorchEnabled.collectAsStateWithLifecycle()
|
||||
var torchReapplied by remember { mutableStateOf(false) }
|
||||
|
||||
// Streaming state
|
||||
val streamState by cameraViewModel.streamState.collectAsStateWithLifecycle()
|
||||
val streamTargetHost by cameraViewModel.streamTargetHost.collectAsStateWithLifecycle()
|
||||
|
||||
val captureController = remember { CameraCaptureController() }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
@@ -245,6 +251,9 @@ fun CameraScreen(
|
||||
isCameraPermissionGranted = cameraPermission.isGranted,
|
||||
onRequestCameraPermission = { cameraPermission.request() },
|
||||
onImportClicked = onImportClicked,
|
||||
streamState = streamState,
|
||||
streamTargetHost = streamTargetHost,
|
||||
onToggleStream = { cameraViewModel.toggleStreaming() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -263,6 +272,9 @@ private fun CameraScreenScaffold(
|
||||
isCameraPermissionGranted: Boolean,
|
||||
onRequestCameraPermission: () -> Unit,
|
||||
onImportClicked: () -> Unit,
|
||||
streamState: StreamState,
|
||||
streamTargetHost: String?,
|
||||
onToggleStream: () -> Unit,
|
||||
) {
|
||||
var focusPoint by remember { mutableStateOf<Offset?>(null) }
|
||||
LaunchedEffect(focusPoint) {
|
||||
@@ -322,6 +334,15 @@ private fun CameraScreenScaffold(
|
||||
val page = cameraUiState.captureState.capturedPage.pageJpeg.toBitmap()
|
||||
CapturedImage(page.asImageBitmap(), thumbnailCoords)
|
||||
}
|
||||
// Stream toggle button - top left of the screen
|
||||
StreamToggleButton(
|
||||
streamState = streamState,
|
||||
streamTargetHost = streamTargetHost,
|
||||
onToggle = onToggleStream,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(top = 48.dp, start = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,6 +426,49 @@ private fun CameraPreviewBox(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamToggleButton(
|
||||
streamState: StreamState,
|
||||
streamTargetHost: String?,
|
||||
onToggle: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val (iconTint, statusText) = when (streamState) {
|
||||
StreamState.Disconnected -> Color.Gray to "图传未连接"
|
||||
StreamState.Connecting -> Color(0xFFFFA000) to "图传连接中..."
|
||||
StreamState.Connected -> Color(0xFF4CAF50) to "图传已连接"
|
||||
is StreamState.Error -> Color(0xFFE53935) to "图传错误"
|
||||
}
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.End,
|
||||
) {
|
||||
IconButton(onClick = onToggle) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Cast,
|
||||
contentDescription = statusText,
|
||||
tint = iconTint,
|
||||
)
|
||||
}
|
||||
if (streamState is StreamState.Connected && streamTargetHost != null) {
|
||||
Text(
|
||||
text = streamTargetHost,
|
||||
color = Color(0xFF4CAF50),
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
}
|
||||
if (streamState is StreamState.Error) {
|
||||
Text(
|
||||
text = streamState.message,
|
||||
color = Color(0xFFE53935),
|
||||
fontSize = 10.sp,
|
||||
modifier = Modifier.padding(end = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CapturedImage(image: ImageBitmap, thumbnailCoords: MutableState<Offset>) {
|
||||
Surface(
|
||||
@@ -718,6 +782,9 @@ private fun ScreenPreview(
|
||||
isCameraPermissionGranted = isCameraPermissionGranted,
|
||||
onRequestCameraPermission = {},
|
||||
onImportClicked = {},
|
||||
streamState = StreamState.Disconnected,
|
||||
streamTargetHost = null,
|
||||
onToggleStream = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,15 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.fairscan.app.AppContainer
|
||||
import org.fairscan.app.domain.CapturedPage
|
||||
import org.fairscan.app.network.ServerEndpoint
|
||||
import org.fairscan.app.network.stream.FrameCompressor
|
||||
import org.fairscan.app.network.stream.FrameDropController
|
||||
import org.fairscan.app.network.stream.StreamState
|
||||
import org.fairscan.app.network.stream.toPreset
|
||||
import org.fairscan.app.platform.extractDocumentFromBitmap
|
||||
import org.fairscan.app.ui.screens.settings.StreamFrameRate
|
||||
import org.fairscan.app.ui.screens.settings.StreamQuality
|
||||
import org.fairscan.app.ui.screens.settings.intervalMs
|
||||
import org.fairscan.imageprocessing.CameraIntrinsics
|
||||
import org.fairscan.imageprocessing.ImageSize
|
||||
import org.fairscan.imageprocessing.OpticalMeasures
|
||||
@@ -51,6 +59,11 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
private val imageLoader = appContainer.imageLoader
|
||||
private val logger = appContainer.logger
|
||||
|
||||
// Streaming components
|
||||
private val streamClient = appContainer.streamClient
|
||||
private val frameCompressor = FrameCompressor()
|
||||
private val frameDropController = FrameDropController()
|
||||
|
||||
private val _events = MutableSharedFlow<CameraEvent>()
|
||||
val events = _events.asSharedFlow()
|
||||
|
||||
@@ -68,10 +81,49 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
private val _isTorchEnabled = MutableStateFlow(false)
|
||||
val isTorchEnabled: StateFlow<Boolean> = _isTorchEnabled
|
||||
|
||||
// Streaming state
|
||||
private val _streamState = MutableStateFlow<StreamState>(StreamState.Disconnected)
|
||||
val streamState: StateFlow<StreamState> = _streamState.asStateFlow()
|
||||
|
||||
private val _streamTargetHost = MutableStateFlow<String?>(null)
|
||||
val streamTargetHost: StateFlow<String?> = _streamTargetHost.asStateFlow()
|
||||
|
||||
private var cachedStreamQuality = StreamQuality.BALANCED
|
||||
private var cachedStreamFrameRate = StreamFrameRate.FPS_10
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
imageSegmentationService.initialize()
|
||||
}
|
||||
// Observe stream client state
|
||||
viewModelScope.launch {
|
||||
streamClient.state.collect { state ->
|
||||
_streamState.value = state
|
||||
}
|
||||
}
|
||||
// Observe stream quality setting
|
||||
viewModelScope.launch {
|
||||
settingsRepository.streamQuality.collect { quality ->
|
||||
cachedStreamQuality = quality
|
||||
}
|
||||
}
|
||||
// Observe stream frame rate setting
|
||||
viewModelScope.launch {
|
||||
settingsRepository.streamFrameRate.collect { rate ->
|
||||
cachedStreamFrameRate = rate
|
||||
}
|
||||
}
|
||||
// Observe server host/port for display
|
||||
viewModelScope.launch {
|
||||
kotlinx.coroutines.flow.combine(
|
||||
settingsRepository.serverHost,
|
||||
settingsRepository.serverPort,
|
||||
) { host, port ->
|
||||
if (host.isNullOrBlank()) null else "$host:$port"
|
||||
}.collect { host ->
|
||||
_streamTargetHost.value = host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetLiveAnalysis() {
|
||||
@@ -103,8 +155,18 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
|
||||
viewModelScope.launch {
|
||||
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
||||
val bitmap = imageProxy.toBitmap()
|
||||
|
||||
// Streaming: send frame if connected (fire-and-forget on IO)
|
||||
val currentHost = _streamTargetHost.value
|
||||
if (_streamState.value is StreamState.Connected && currentHost != null) {
|
||||
launch(Dispatchers.IO) {
|
||||
sendStreamFrame(bitmap)
|
||||
}
|
||||
}
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
imageSegmentationService.runSegmentationAndReturn(imageProxy.toBitmap())
|
||||
imageSegmentationService.runSegmentationAndReturn(bitmap)
|
||||
}
|
||||
|
||||
result?.let {
|
||||
@@ -230,6 +292,58 @@ class CameraViewModel(appContainer: AppContainer): ViewModel() {
|
||||
importJob = null
|
||||
_importState.value = ImportState.Idle
|
||||
}
|
||||
|
||||
// ── Streaming ──
|
||||
|
||||
fun toggleStreaming() {
|
||||
viewModelScope.launch {
|
||||
when (_streamState.value) {
|
||||
is StreamState.Disconnected, is StreamState.Error -> startStreaming()
|
||||
is StreamState.Connected -> stopStreaming()
|
||||
else -> { /* Connecting — ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun startStreaming() {
|
||||
val host = settingsRepository.serverHost.first()
|
||||
val port = settingsRepository.serverPort.first()
|
||||
if (host.isNullOrBlank()) {
|
||||
_streamState.value = StreamState.Error("未配置主机地址")
|
||||
return
|
||||
}
|
||||
frameDropController.reset()
|
||||
streamClient.connect(ServerEndpoint(host, port))
|
||||
}
|
||||
|
||||
private suspend fun stopStreaming() {
|
||||
streamClient.disconnect()
|
||||
frameDropController.reset()
|
||||
}
|
||||
|
||||
private suspend fun sendStreamFrame(bitmap: Bitmap) {
|
||||
if (_streamState.value !is StreamState.Connected) return
|
||||
|
||||
val preset = cachedStreamQuality.toPreset()
|
||||
// Use explicit frame rate if set, otherwise fall back to quality preset's default
|
||||
val intervalMs = cachedStreamFrameRate.intervalMs ?: preset.minIntervalMs
|
||||
if (frameDropController.shouldDrop(intervalMs)) return
|
||||
|
||||
frameDropController.markSending(true)
|
||||
try {
|
||||
val compressed = withContext(Dispatchers.IO) {
|
||||
frameCompressor.compress(bitmap, preset.maxResolution, preset.jpegQuality)
|
||||
}
|
||||
if (compressed != null) {
|
||||
frameDropController.onFrameSent()
|
||||
streamClient.sendFrame(compressed)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// Frame send failed — drop silently, don't affect capture
|
||||
} finally {
|
||||
frameDropController.markSending(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class CaptureState {
|
||||
|
||||
@@ -17,8 +17,11 @@ package org.fairscan.app.ui.screens.export
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.text.format.Formatter
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -32,6 +35,7 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -45,14 +49,18 @@ import androidx.compose.material.icons.automirrored.filled.OpenInNew
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Clear
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.CloudUpload
|
||||
import androidx.compose.material.icons.filled.Done
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material.icons.filled.Error
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -108,6 +116,7 @@ fun ExportScreenWrapper(
|
||||
uiState: ExportUiState,
|
||||
currentDocument: DocumentUiModel,
|
||||
pdfActions: ExportActions,
|
||||
taskPanelState: TaskPanelState,
|
||||
onCloseScan: () -> Unit,
|
||||
) {
|
||||
BackHandler { navigation.back() }
|
||||
@@ -122,24 +131,37 @@ fun ExportScreenWrapper(
|
||||
pdfActions.setFilename(newName)
|
||||
}
|
||||
|
||||
val isBusy = uiState.isSaving
|
||||
|| uiState.uploadState is UploadState.Uploading
|
||||
|| taskPanelState.downloadState is DownloadState.Downloading
|
||||
|
||||
ExportScreen(
|
||||
onFilenameChange = onFilenameChange,
|
||||
uiState = uiState,
|
||||
currentDocument = currentDocument,
|
||||
navigation = navigation,
|
||||
taskPanelState = taskPanelState,
|
||||
onShare = {
|
||||
if (!uiState.isSaving) {
|
||||
pdfActions.share()
|
||||
}
|
||||
if (!isBusy) pdfActions.share()
|
||||
},
|
||||
onSave = {
|
||||
if (!uiState.isSaving) {
|
||||
pdfActions.save()
|
||||
}
|
||||
if (!isBusy) pdfActions.save()
|
||||
},
|
||||
onOpen = pdfActions.open,
|
||||
onUploadToPc = {
|
||||
if (!isBusy && uiState.uploadState !is UploadState.Uploading) {
|
||||
pdfActions.uploadToPc()
|
||||
}
|
||||
},
|
||||
onUploadAndProcess = { processType ->
|
||||
if (!isBusy) {
|
||||
pdfActions.uploadAndProcess(processType)
|
||||
}
|
||||
},
|
||||
onDownloadResult = pdfActions.downloadResult,
|
||||
onResetDownloadState = pdfActions.resetDownloadState,
|
||||
onCloseScan = {
|
||||
if (!uiState.isSaving) {
|
||||
if (!isBusy) {
|
||||
if (uiState.hasSavedOrShared || uiState.isResumedScan)
|
||||
onCloseScan()
|
||||
else
|
||||
@@ -160,9 +182,14 @@ fun ExportScreen(
|
||||
uiState: ExportUiState,
|
||||
currentDocument: DocumentUiModel,
|
||||
navigation: Navigation,
|
||||
taskPanelState: TaskPanelState = TaskPanelState(),
|
||||
onShare: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onOpen: (SavedItem) -> Unit,
|
||||
onUploadToPc: () -> Unit,
|
||||
onUploadAndProcess: (processType: String) -> Unit,
|
||||
onDownloadResult: (RemoteTask, Uri, Context) -> Unit = { _, _, _ -> },
|
||||
onResetDownloadState: () -> Unit = {},
|
||||
onCloseScan: () -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
@@ -187,7 +214,7 @@ fun ExportScreen(
|
||||
) {
|
||||
PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
|
||||
Spacer(Modifier.weight(1f)) // push buttons down
|
||||
MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
|
||||
MainActions(onFilenameChange, uiState, onShare, onSave, onUploadToPc, onUploadAndProcess, onCloseScan, taskPanelState, onDownloadResult, onResetDownloadState)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
@@ -202,7 +229,7 @@ fun ExportScreen(
|
||||
PdfInfosAndResultBar(uiState, currentDocument, onOpen, onThumbnailClick)
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
MainActions(onFilenameChange, uiState, onShare, onSave, onCloseScan)
|
||||
MainActions(onFilenameChange, uiState, onShare, onSave, onUploadToPc, onUploadAndProcess, onCloseScan, taskPanelState, onDownloadResult, onResetDownloadState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,7 +390,12 @@ private fun MainActions(
|
||||
uiState: ExportUiState,
|
||||
onShare: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onUploadToPc: () -> Unit,
|
||||
onUploadAndProcess: (processType: String) -> Unit,
|
||||
onCloseScan: () -> Unit,
|
||||
taskPanelState: TaskPanelState = TaskPanelState(),
|
||||
onDownloadResult: (RemoteTask, Uri, Context) -> Unit = { _, _, _ -> },
|
||||
onResetDownloadState: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
@@ -394,6 +426,13 @@ private fun MainActions(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Upload to PC server
|
||||
UploadToPcSection(uiState, onUploadToPc, onUploadAndProcess)
|
||||
|
||||
// Task management panel
|
||||
TaskPanelSection(taskPanelState, onDownloadResult, onResetDownloadState)
|
||||
|
||||
ExportButton(
|
||||
icon = Icons.Default.Done,
|
||||
text = stringResource(R.string.scan_button),
|
||||
@@ -404,6 +443,432 @@ private fun MainActions(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun UploadToPcSection(
|
||||
uiState: ExportUiState,
|
||||
onUploadToPc: () -> Unit,
|
||||
onUploadAndProcess: (processType: String) -> Unit,
|
||||
) {
|
||||
when (val uploadState = uiState.uploadState) {
|
||||
is UploadState.Idle -> {
|
||||
if (uiState.result is ExportResult.Pdf) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ExportButton(
|
||||
icon = Icons.Default.CloudUpload,
|
||||
text = "仅传输到电脑",
|
||||
onClick = onUploadToPc,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isPrimary = false,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
ExportButton(
|
||||
icon = Icons.Default.CloudUpload,
|
||||
text = "上传并处理 (OCR PDF)",
|
||||
onClick = { onUploadAndProcess("ocrpdf") },
|
||||
modifier = Modifier.weight(1f),
|
||||
isPrimary = false,
|
||||
)
|
||||
ExportButton(
|
||||
icon = Icons.Default.CloudUpload,
|
||||
text = "上传并处理 (Markdown)",
|
||||
onClick = { onUploadAndProcess("markdown") },
|
||||
modifier = Modifier.weight(1f),
|
||||
isPrimary = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is UploadState.Uploading -> {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
tonalElevation = 1.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
"上传到电脑中...",
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
val progressPercent = (uploadState.progress * 100).toInt()
|
||||
LinearProgressIndicator(
|
||||
progress = { uploadState.progress },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Text(
|
||||
"$progressPercent%",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
is UploadState.Uploaded -> {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column {
|
||||
Text("已上传到电脑", style = MaterialTheme.typography.bodyMedium)
|
||||
val statusText = if (uploadState.taskId != null) {
|
||||
"处理任务已创建 (${uploadState.taskId.take(8)}...)"
|
||||
} else {
|
||||
"仅传输,未处理"
|
||||
}
|
||||
Text(
|
||||
statusText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is UploadState.Error -> {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
|
||||
tonalElevation = 0.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(
|
||||
"上传失败",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
Text(
|
||||
uploadState.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onUploadToPc,
|
||||
modifier = Modifier.align(Alignment.End)
|
||||
) {
|
||||
Text("重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskPanelSection(
|
||||
taskPanelState: TaskPanelState,
|
||||
onDownloadResult: (RemoteTask, Uri, Context) -> Unit,
|
||||
onResetDownloadState: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val tasks = taskPanelState.tasks
|
||||
|
||||
// Track selected destination directories per task
|
||||
val selectedDirs = remember { mutableStateOf(mapOf<String, Uri>()) }
|
||||
// Track which task's directory picker is active
|
||||
val pickingForTask = remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val dirPickerLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.OpenDocumentTree()
|
||||
) { uri ->
|
||||
val taskId = pickingForTask.value
|
||||
if (taskId != null && uri != null) {
|
||||
// Take persistent permission
|
||||
context.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION or
|
||||
android.content.Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
)
|
||||
selectedDirs.value = selectedDirs.value + (taskId to uri)
|
||||
}
|
||||
pickingForTask.value = null
|
||||
}
|
||||
|
||||
if (tasks.isEmpty()) return
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
tonalElevation = 1.dp,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
"任务管理",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
|
||||
tasks.forEach { task ->
|
||||
TaskRow(
|
||||
task = task,
|
||||
isDownloading = taskPanelState.downloadState is DownloadState.Downloading
|
||||
&& (taskPanelState.downloadState as DownloadState.Downloading).taskId == task.taskId,
|
||||
downloadProgress = if (taskPanelState.downloadState is DownloadState.Downloading
|
||||
&& (taskPanelState.downloadState as DownloadState.Downloading).taskId == task.taskId
|
||||
) (taskPanelState.downloadState as DownloadState.Downloading).progress else 0f,
|
||||
isDownloaded = taskPanelState.downloadState is DownloadState.Downloaded
|
||||
&& (taskPanelState.downloadState as DownloadState.Downloaded).taskId == task.taskId,
|
||||
downloadedUri = if (taskPanelState.downloadState is DownloadState.Downloaded
|
||||
&& (taskPanelState.downloadState as DownloadState.Downloaded).taskId == task.taskId
|
||||
) (taskPanelState.downloadState as DownloadState.Downloaded).fileUri else null,
|
||||
downloadError = if (taskPanelState.downloadState is DownloadState.Error
|
||||
&& (taskPanelState.downloadState as DownloadState.Error).taskId == task.taskId)
|
||||
(taskPanelState.downloadState as DownloadState.Error).message else null,
|
||||
selectedDirUri = selectedDirs.value[task.taskId],
|
||||
onSelectDir = {
|
||||
pickingForTask.value = task.taskId
|
||||
dirPickerLauncher.launch(null)
|
||||
},
|
||||
onDownload = { destUri ->
|
||||
onDownloadResult(task, destUri, context)
|
||||
},
|
||||
onOpenDownloaded = {
|
||||
// Open the downloaded file
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW).apply {
|
||||
val uri = if (taskPanelState.downloadState is DownloadState.Downloaded
|
||||
&& (taskPanelState.downloadState as DownloadState.Downloaded).taskId == task.taskId
|
||||
) (taskPanelState.downloadState as DownloadState.Downloaded).fileUri else return@apply
|
||||
setDataAndType(uri, when (task.processType) {
|
||||
"markdown" -> "application/zip"
|
||||
else -> "application/pdf"
|
||||
})
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
try {
|
||||
context.startActivity(intent)
|
||||
} catch (_: Exception) {
|
||||
// No app to handle this file type
|
||||
}
|
||||
},
|
||||
onDismissError = onResetDownloadState,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TaskRow(
|
||||
task: RemoteTask,
|
||||
isDownloading: Boolean,
|
||||
downloadProgress: Float,
|
||||
isDownloaded: Boolean,
|
||||
downloadedUri: android.net.Uri?,
|
||||
downloadError: String?,
|
||||
selectedDirUri: android.net.Uri?,
|
||||
onSelectDir: () -> Unit,
|
||||
onDownload: (Uri) -> Unit,
|
||||
onOpenDownloaded: () -> Unit,
|
||||
onDismissError: () -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
// Row 1: file name + type badge + status
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
) {
|
||||
Text(
|
||||
text = task.fileName.ifEmpty { task.taskId.take(8) + "..." },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
)
|
||||
|
||||
// Process type badge
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f),
|
||||
) {
|
||||
Text(
|
||||
text = task.processType,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
|
||||
// Status badge
|
||||
val (statusText, statusColor) = when (task.status) {
|
||||
"queued" -> "排队中" to Color(0xFFFFA726)
|
||||
"processing" -> "处理中" to Color(0xFF42A5F5)
|
||||
"completed" -> "已完成" to Color(0xFF66BB6A)
|
||||
"failed" -> "失败" to Color(0xFFEF5350)
|
||||
else -> task.status to Color.Gray
|
||||
}
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = statusColor,
|
||||
)
|
||||
}
|
||||
|
||||
// Row 2: Progress bar (for processing tasks)
|
||||
if (task.status == "processing") {
|
||||
LinearProgressIndicator(
|
||||
progress = { task.progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
|
||||
// Row 3: Error message (for failed tasks)
|
||||
if (task.status == "failed" && task.message.isNotEmpty()) {
|
||||
Text(
|
||||
text = task.message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error.copy(alpha = 0.8f),
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
|
||||
// Row 4: Download actions for completed tasks
|
||||
if (task.status == "completed" && !isDownloaded) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (selectedDirUri != null) {
|
||||
Button(
|
||||
onClick = { onDownload(selectedDirUri) },
|
||||
enabled = !isDownloading,
|
||||
contentPadding = PaddingValues(
|
||||
start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp
|
||||
),
|
||||
modifier = Modifier.heightIn(min = 32.dp)
|
||||
) {
|
||||
if (isDownloading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(14.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
"${(downloadProgress * 100).toInt()}%",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
} else {
|
||||
Icon(Icons.Default.Download, contentDescription = null, modifier = Modifier.size(14.dp))
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("下载", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onSelectDir,
|
||||
contentPadding = PaddingValues(
|
||||
start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp
|
||||
),
|
||||
modifier = Modifier.heightIn(min = 32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Download, // reuse download icon for simplicity
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
if (selectedDirUri != null) "已选目录" else "选择目录",
|
||||
style = MaterialTheme.typography.labelSmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Row 5: Downloaded state with open button
|
||||
if (isDownloaded) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Text(
|
||||
"已下载",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
OutlinedButton(
|
||||
onClick = onOpenDownloaded,
|
||||
contentPadding = PaddingValues(
|
||||
start = 10.dp, end = 10.dp, top = 4.dp, bottom = 4.dp
|
||||
),
|
||||
modifier = Modifier.heightIn(min = 32.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text("打开", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Row 6: Download error
|
||||
if (downloadError != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Error,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
Text(
|
||||
downloadError,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionSurface(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -716,6 +1181,8 @@ fun ExportPreviewToCustomize(uiState: ExportUiState) {
|
||||
onShare = {},
|
||||
onSave = {},
|
||||
onOpen = {},
|
||||
onUploadToPc = {},
|
||||
onUploadAndProcess = {},
|
||||
onCloseScan = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,10 +27,55 @@ data class ExportUiState(
|
||||
val hasShared: Boolean = false,
|
||||
val error: ExportError? = null,
|
||||
val isResumedScan: Boolean = false,
|
||||
// Upload to PC
|
||||
val uploadState: UploadState = UploadState.Idle,
|
||||
// Task management panel
|
||||
val taskPanelState: TaskPanelState = TaskPanelState(),
|
||||
) {
|
||||
val hasSavedOrShared get() = savedBundle != null || hasShared
|
||||
}
|
||||
|
||||
/** State of the PDF upload to PC server. */
|
||||
sealed class UploadState {
|
||||
/** No upload in progress. */
|
||||
data object Idle : UploadState()
|
||||
|
||||
/** Upload is in progress with [progress] 0.0..1.0. */
|
||||
data class Uploading(val progress: Float) : UploadState()
|
||||
|
||||
/** Upload completed successfully. taskId is set when processing was also requested. */
|
||||
data class Uploaded(val fileId: String, val taskId: String? = null) : UploadState()
|
||||
|
||||
/** Upload failed with an error message. */
|
||||
data class Error(val message: String) : UploadState()
|
||||
}
|
||||
|
||||
/** A remote processing task displayed in the task management panel. */
|
||||
data class RemoteTask(
|
||||
val fileId: String,
|
||||
val taskId: String,
|
||||
val processType: String, // "ocrpdf" | "markdown"
|
||||
val status: String, // "queued" | "processing" | "completed" | "failed"
|
||||
val progress: Int, // 0..100
|
||||
val fileName: String = "",
|
||||
val message: String = "",
|
||||
)
|
||||
|
||||
/** State for the task management panel. */
|
||||
data class TaskPanelState(
|
||||
val tasks: List<RemoteTask> = emptyList(),
|
||||
val downloadState: DownloadState = DownloadState.Idle,
|
||||
)
|
||||
|
||||
/** Download state for a task artifact. */
|
||||
sealed class DownloadState {
|
||||
data object Idle : DownloadState()
|
||||
data class Downloading(val taskId: String, val progress: Float) : DownloadState()
|
||||
/** Download completed, providing the local file URI for the user to open. */
|
||||
data class Downloaded(val taskId: String, val fileUri: android.net.Uri) : DownloadState()
|
||||
data class Error(val taskId: String, val message: String) : DownloadState()
|
||||
}
|
||||
|
||||
data class SavedItem(
|
||||
val uri: Uri,
|
||||
val fileName: String,
|
||||
|
||||
@@ -31,6 +31,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -48,6 +49,9 @@ import org.fairscan.app.domain.ExportQuality
|
||||
import org.fairscan.app.domain.PageViewKey
|
||||
import org.fairscan.app.domain.pagesToExport
|
||||
import org.fairscan.app.ui.screens.settings.ExportFormat
|
||||
import org.fairscan.app.network.ServerEndpoint
|
||||
import org.fairscan.app.network.tasks.TaskClient
|
||||
import org.fairscan.app.network.upload.PdfUploadClient
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.io.IOException
|
||||
@@ -69,6 +73,8 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
private val fileManager = container.fileManager
|
||||
private val settingsRepository = container.settingsRepository
|
||||
private val logger = container.logger
|
||||
private val pdfUploadClient = container.pdfUploadClient
|
||||
private val taskClient = container.taskClient
|
||||
|
||||
private val _events = MutableSharedFlow<ExportEvent>()
|
||||
val events = _events.asSharedFlow()
|
||||
@@ -93,6 +99,12 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
private val _uiState = MutableStateFlow(ExportUiState())
|
||||
val uiState: StateFlow<ExportUiState> = _uiState.asStateFlow()
|
||||
|
||||
// Task management panel
|
||||
private val _taskPanelState = MutableStateFlow(TaskPanelState())
|
||||
val taskPanelState: StateFlow<TaskPanelState> = _taskPanelState.asStateFlow()
|
||||
|
||||
private val activePollingJobs = mutableMapOf<String, Job>()
|
||||
|
||||
private var resumedScanKeys: List<PageViewKey> = emptyList()
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
@@ -397,6 +409,236 @@ class ExportViewModel(container: AppContainer, val imageRepository: ImageReposit
|
||||
fileManager.cleanUpOldFiles(thresholdInMillis)
|
||||
}
|
||||
|
||||
fun uploadPdfToServer() {
|
||||
val result = _uiState.value.result ?: return
|
||||
if (result !is ExportResult.Pdf) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(uploadState = UploadState.Uploading(0f)) }
|
||||
try {
|
||||
val endpoint = resolveServerEndpoint() ?: return@launch
|
||||
|
||||
val uploadResult = withContext(Dispatchers.IO) {
|
||||
pdfUploadClient.uploadPdf(endpoint, result.file) { progress ->
|
||||
_uiState.update { it.copy(uploadState = UploadState.Uploading(progress)) }
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.update {
|
||||
it.copy(uploadState = UploadState.Uploaded(uploadResult.fileId))
|
||||
}
|
||||
android.util.Log.i("Upload", "PDF uploaded, fileId=${uploadResult.fileId}")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("Upload", "Failed to upload PDF", e)
|
||||
_uiState.update {
|
||||
it.copy(uploadState = UploadState.Error(e.message ?: "上传失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun uploadAndProcess(processType: String = "ocrpdf") {
|
||||
val result = _uiState.value.result ?: return
|
||||
if (result !is ExportResult.Pdf) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(uploadState = UploadState.Uploading(0f)) }
|
||||
try {
|
||||
val endpoint = resolveServerEndpoint() ?: return@launch
|
||||
|
||||
// Step 1: Upload
|
||||
val uploadResult = withContext(Dispatchers.IO) {
|
||||
pdfUploadClient.uploadPdf(endpoint, result.file) { progress ->
|
||||
_uiState.update { it.copy(uploadState = UploadState.Uploading(progress)) }
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Create processing task
|
||||
val taskResult = withContext(Dispatchers.IO) {
|
||||
taskClient.processPdf(endpoint, uploadResult.fileId, processType)
|
||||
}
|
||||
|
||||
// Step 3: Add task to panel
|
||||
val remoteTask = RemoteTask(
|
||||
fileId = uploadResult.fileId,
|
||||
taskId = taskResult.taskId,
|
||||
processType = processType,
|
||||
status = "queued",
|
||||
progress = 0,
|
||||
fileName = result.file.name,
|
||||
)
|
||||
_taskPanelState.update { state ->
|
||||
state.copy(tasks = state.tasks + remoteTask)
|
||||
}
|
||||
|
||||
// Step 4: Start background polling
|
||||
startPolling(remoteTask, endpoint)
|
||||
|
||||
_uiState.update {
|
||||
it.copy(uploadState = UploadState.Uploaded(uploadResult.fileId, taskResult.taskId))
|
||||
}
|
||||
android.util.Log.i("Upload", "PDF uploaded + task created: file=${uploadResult.fileId}, task=${taskResult.taskId}")
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("Upload", "Failed to upload & process PDF", e)
|
||||
_uiState.update {
|
||||
it.copy(uploadState = UploadState.Error(e.message ?: "上传处理失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startPolling(task: RemoteTask, endpoint: ServerEndpoint) {
|
||||
val job = viewModelScope.launch {
|
||||
try {
|
||||
while (true) {
|
||||
delay(2000)
|
||||
val taskStatus = withContext(Dispatchers.IO) {
|
||||
taskClient.getTaskStatus(endpoint, task.taskId)
|
||||
}
|
||||
_taskPanelState.update { state ->
|
||||
val updated = state.tasks.map { t ->
|
||||
if (t.taskId == task.taskId) {
|
||||
t.copy(
|
||||
status = taskStatus.status,
|
||||
progress = taskStatus.progress,
|
||||
message = taskStatus.message,
|
||||
)
|
||||
} else t
|
||||
}
|
||||
state.copy(tasks = updated)
|
||||
}
|
||||
if (taskStatus.status == "completed" || taskStatus.status == "failed") {
|
||||
activePollingJobs.remove(task.taskId)
|
||||
return@launch
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Polling cancelled
|
||||
} catch (e: Exception) {
|
||||
_taskPanelState.update { state ->
|
||||
val updated = state.tasks.map { t ->
|
||||
if (t.taskId == task.taskId) {
|
||||
t.copy(status = "failed", message = e.message ?: "轮询失败")
|
||||
} else t
|
||||
}
|
||||
state.copy(tasks = updated)
|
||||
}
|
||||
activePollingJobs.remove(task.taskId)
|
||||
}
|
||||
}
|
||||
activePollingJobs[task.taskId] = job
|
||||
}
|
||||
|
||||
fun downloadResult(task: RemoteTask, destDirUri: Uri, context: Context) {
|
||||
viewModelScope.launch {
|
||||
_taskPanelState.update { it.copy(downloadState = DownloadState.Downloading(task.taskId, 0f)) }
|
||||
try {
|
||||
val endpoint = resolveServerEndpointForTask(task.taskId) ?: return@launch
|
||||
|
||||
// List artifacts to find the preferred one
|
||||
val artifacts = withContext(Dispatchers.IO) {
|
||||
taskClient.listArtifacts(endpoint, task.taskId)
|
||||
}
|
||||
|
||||
// Prefer ZIP for markdown, PDF for ocrpdf
|
||||
val preferredArtifact = if (task.processType == "markdown") {
|
||||
artifacts.find { it.fileType == "zip" } ?: artifacts.firstOrNull()
|
||||
} else {
|
||||
artifacts.find { it.fileType == "pdf" } ?: artifacts.firstOrNull()
|
||||
}
|
||||
|
||||
if (preferredArtifact == null) {
|
||||
_taskPanelState.update { it.copy(downloadState = DownloadState.Error(task.taskId, "没有可下载的产物")) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Download to a temp file first, then copy to SAF
|
||||
val tempFile = File(preparationDir, preferredArtifact.fileName)
|
||||
withContext(Dispatchers.IO) {
|
||||
taskClient.downloadArtifact(endpoint, preferredArtifact.artifactId, tempFile) { progress ->
|
||||
_taskPanelState.update {
|
||||
it.copy(downloadState = DownloadState.Downloading(task.taskId, progress))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy to SAF directory
|
||||
val safFile = withContext(Dispatchers.IO) {
|
||||
val tree = DocumentFile.fromTreeUri(context, destDirUri)
|
||||
?: throw IllegalStateException("Invalid SAF directory")
|
||||
val target = tree.createFile(
|
||||
preferredArtifact.fileType.let {
|
||||
when (it) {
|
||||
"zip" -> "application/zip"
|
||||
"pdf" -> "application/pdf"
|
||||
else -> "text/markdown"
|
||||
}
|
||||
},
|
||||
preferredArtifact.fileName
|
||||
) ?: throw IllegalStateException("Unable to create file in SAF directory")
|
||||
|
||||
context.contentResolver.openOutputStream(target.uri)?.use { output ->
|
||||
tempFile.inputStream().use { input ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
} ?: throw IllegalStateException("Failed to open SAF output stream")
|
||||
|
||||
target
|
||||
}
|
||||
|
||||
// Clean up temp file
|
||||
tempFile.delete()
|
||||
|
||||
_taskPanelState.update {
|
||||
it.copy(downloadState = DownloadState.Downloaded(task.taskId, safFile.uri))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e("Download", "Failed to download artifact", e)
|
||||
_taskPanelState.update {
|
||||
it.copy(downloadState = DownloadState.Error(task.taskId, e.message ?: "下载失败"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetDownloadState() {
|
||||
_taskPanelState.update { it.copy(downloadState = DownloadState.Idle) }
|
||||
}
|
||||
|
||||
private suspend fun resolveServerEndpointForTask(taskId: String): ServerEndpoint? {
|
||||
val host = settingsRepository.serverHost.first()
|
||||
val port = settingsRepository.serverPort.first()
|
||||
if (host.isNullOrBlank()) {
|
||||
_taskPanelState.update {
|
||||
it.copy(downloadState = DownloadState.Error(taskId, "未配置服务器地址"))
|
||||
}
|
||||
return null
|
||||
}
|
||||
return ServerEndpoint(host, port)
|
||||
}
|
||||
|
||||
private suspend fun resolveServerEndpoint(): ServerEndpoint? {
|
||||
val host = settingsRepository.serverHost.first()
|
||||
val port = settingsRepository.serverPort.first()
|
||||
if (host.isNullOrBlank()) {
|
||||
_uiState.update {
|
||||
it.copy(uploadState = UploadState.Error("未配置服务器地址"))
|
||||
}
|
||||
return null
|
||||
}
|
||||
return ServerEndpoint(host, port)
|
||||
}
|
||||
|
||||
fun resetUploadState() {
|
||||
_uiState.update { it.copy(uploadState = UploadState.Idle) }
|
||||
}
|
||||
|
||||
private fun resolveExportDirName(context: Context, exportDirUri: Uri?): String? {
|
||||
return if (exportDirUri == null) {
|
||||
null
|
||||
@@ -443,6 +685,10 @@ data class ExportActions(
|
||||
val share: () -> Unit,
|
||||
val save: () -> Unit,
|
||||
val open: (SavedItem) -> Unit,
|
||||
val uploadToPc: () -> Unit,
|
||||
val uploadAndProcess: (processType: String) -> Unit,
|
||||
val downloadResult: (RemoteTask, Uri, Context) -> Unit = { _, _, _ -> },
|
||||
val resetDownloadState: () -> Unit = {},
|
||||
)
|
||||
|
||||
class MissingExportDirPermissionException(
|
||||
|
||||
@@ -35,6 +35,16 @@ class SettingsRepository(private val context: Context) {
|
||||
private val EXPORT_FORMAT = stringPreferencesKey("export_format")
|
||||
private val EXPORT_QUALITY = stringPreferencesKey("export_quality")
|
||||
|
||||
// Network collaboration settings
|
||||
private val SERVER_HOST = stringPreferencesKey("server_host")
|
||||
private val SERVER_PORT = stringPreferencesKey("server_port")
|
||||
private val SERVER_DISPLAY_NAME = stringPreferencesKey("server_display_name")
|
||||
private val LAST_SELECTED_SERVICE_ID = stringPreferencesKey("last_selected_service_id")
|
||||
private val STREAM_QUALITY = stringPreferencesKey("stream_quality")
|
||||
private val POST_PROCESS_MODE = stringPreferencesKey("post_process_mode")
|
||||
private val AUTO_DOWNLOAD_PROCESSED_RESULT = stringPreferencesKey("auto_download_processed_result")
|
||||
private val STREAM_FRAME_RATE = stringPreferencesKey("stream_frame_rate")
|
||||
|
||||
val defaultColorMode: Flow<DefaultColorMode> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
when (prefs[DEFAULT_COLOR_MODE]) {
|
||||
@@ -73,6 +83,61 @@ class SettingsRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val serverHost: Flow<String?> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
prefs[SERVER_HOST]
|
||||
}
|
||||
|
||||
val serverPort: Flow<Int> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
prefs[SERVER_PORT]?.toIntOrNull() ?: 2026
|
||||
}
|
||||
|
||||
val serverDisplayName: Flow<String?> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
prefs[SERVER_DISPLAY_NAME]
|
||||
}
|
||||
|
||||
val lastSelectedServiceId: Flow<String?> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
prefs[LAST_SELECTED_SERVICE_ID]
|
||||
}
|
||||
|
||||
val streamQuality: Flow<StreamQuality> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
when (prefs[STREAM_QUALITY]) {
|
||||
"LOW" -> StreamQuality.LOW
|
||||
"HIGH" -> StreamQuality.HIGH
|
||||
"BALANCED", null -> StreamQuality.BALANCED
|
||||
else -> StreamQuality.BALANCED
|
||||
}
|
||||
}
|
||||
|
||||
val postProcessMode: Flow<PostProcessMode> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
when (prefs[POST_PROCESS_MODE]) {
|
||||
"MARKDOWN" -> PostProcessMode.MARKDOWN
|
||||
"OCRPDF", null -> PostProcessMode.OCRPDF
|
||||
else -> PostProcessMode.OCRPDF
|
||||
}
|
||||
}
|
||||
|
||||
val autoDownloadProcessedResult: Flow<Boolean> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
prefs[AUTO_DOWNLOAD_PROCESSED_RESULT]?.toBoolean() ?: false
|
||||
}
|
||||
|
||||
val streamFrameRate: Flow<StreamFrameRate> =
|
||||
context.dataStore.data.map { prefs ->
|
||||
when (prefs[STREAM_FRAME_RATE]) {
|
||||
"UNLIMITED" -> StreamFrameRate.UNLIMITED
|
||||
"FPS_15" -> StreamFrameRate.FPS_15
|
||||
"FPS_5" -> StreamFrameRate.FPS_5
|
||||
"FPS_10", null -> StreamFrameRate.FPS_10
|
||||
else -> StreamFrameRate.FPS_10
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDefaultColorMode(mode: DefaultColorMode) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[DEFAULT_COLOR_MODE] = mode.name
|
||||
@@ -100,6 +165,66 @@ class SettingsRepository(private val context: Context) {
|
||||
prefs[EXPORT_QUALITY] = quality.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setServerHost(host: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (host == null) {
|
||||
prefs.remove(SERVER_HOST)
|
||||
} else {
|
||||
prefs[SERVER_HOST] = host
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setServerPort(port: Int) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[SERVER_PORT] = port.toString()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setServerDisplayName(name: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (name == null) {
|
||||
prefs.remove(SERVER_DISPLAY_NAME)
|
||||
} else {
|
||||
prefs[SERVER_DISPLAY_NAME] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setLastSelectedServiceId(id: String?) {
|
||||
context.dataStore.edit { prefs ->
|
||||
if (id == null) {
|
||||
prefs.remove(LAST_SELECTED_SERVICE_ID)
|
||||
} else {
|
||||
prefs[LAST_SELECTED_SERVICE_ID] = id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setStreamQuality(quality: StreamQuality) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[STREAM_QUALITY] = quality.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPostProcessMode(mode: PostProcessMode) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[POST_PROCESS_MODE] = mode.name
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setAutoDownloadProcessedResult(enabled: Boolean) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[AUTO_DOWNLOAD_PROCESSED_RESULT] = enabled.toString()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setStreamFrameRate(rate: StreamFrameRate) {
|
||||
context.dataStore.edit { prefs ->
|
||||
prefs[STREAM_FRAME_RATE] = rate.name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class DefaultColorMode(val colorMode: ColorMode?, val labelResource: Int) {
|
||||
@@ -112,3 +237,28 @@ enum class ExportFormat(val mimeType: String) {
|
||||
PDF("application/pdf"),
|
||||
JPEG("image/jpeg"),
|
||||
}
|
||||
|
||||
enum class StreamQuality {
|
||||
LOW,
|
||||
BALANCED,
|
||||
HIGH,
|
||||
}
|
||||
|
||||
enum class PostProcessMode {
|
||||
MARKDOWN,
|
||||
OCRPDF,
|
||||
}
|
||||
|
||||
enum class StreamFrameRate(val labelRes: Int, val uiLabel: String) {
|
||||
UNLIMITED(0, "无限制"),
|
||||
FPS_15(0, "15 fps"),
|
||||
FPS_10(0, "10 fps"),
|
||||
FPS_5(0, "5 fps"),
|
||||
}
|
||||
|
||||
val StreamFrameRate.intervalMs: Long? get() = when (this) {
|
||||
StreamFrameRate.UNLIMITED -> null
|
||||
StreamFrameRate.FPS_15 -> 66L
|
||||
StreamFrameRate.FPS_10 -> 100L
|
||||
StreamFrameRate.FPS_5 -> 200L
|
||||
}
|
||||
|
||||
@@ -30,21 +30,29 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Folder
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.fairscan.app.R
|
||||
@@ -61,6 +69,14 @@ fun SettingsScreen(
|
||||
onResetExportDirClick: () -> Unit,
|
||||
onExportFormatChanged: (ExportFormat) -> Unit,
|
||||
onExportQualityChanged: (ExportQuality) -> Unit,
|
||||
onServerHostChanged: (String?) -> Unit,
|
||||
onServerPortChanged: (Int) -> Unit,
|
||||
onStreamQualityChanged: (StreamQuality) -> Unit,
|
||||
onPostProcessModeChanged: (PostProcessMode) -> Unit,
|
||||
onAutoDownloadChanged: (Boolean) -> Unit,
|
||||
onStreamFrameRateChanged: (StreamFrameRate) -> Unit,
|
||||
onScanNetworkHostsClick: () -> Unit,
|
||||
onTestConnectionClick: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
BackHandler { onBack() }
|
||||
@@ -79,6 +95,14 @@ fun SettingsScreen(
|
||||
onResetExportDirClick,
|
||||
onExportFormatChanged,
|
||||
onExportQualityChanged,
|
||||
onServerHostChanged,
|
||||
onServerPortChanged,
|
||||
onStreamQualityChanged,
|
||||
onPostProcessModeChanged,
|
||||
onAutoDownloadChanged,
|
||||
onStreamFrameRateChanged,
|
||||
onScanNetworkHostsClick,
|
||||
onTestConnectionClick,
|
||||
modifier = Modifier.padding(paddingValues))
|
||||
}
|
||||
}
|
||||
@@ -91,6 +115,14 @@ private fun SettingsContent(
|
||||
onResetExportDirClick: () -> Unit,
|
||||
onExportFormatChanged: (ExportFormat) -> Unit,
|
||||
onExportQualityChanged: (ExportQuality) -> Unit,
|
||||
onServerHostChanged: (String?) -> Unit,
|
||||
onServerPortChanged: (Int) -> Unit,
|
||||
onStreamQualityChanged: (StreamQuality) -> Unit,
|
||||
onPostProcessModeChanged: (PostProcessMode) -> Unit,
|
||||
onAutoDownloadChanged: (Boolean) -> Unit,
|
||||
onStreamFrameRateChanged: (StreamFrameRate) -> Unit,
|
||||
onScanNetworkHostsClick: () -> Unit,
|
||||
onTestConnectionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val (folderLabel, folderLabelColor) = when {
|
||||
@@ -170,6 +202,157 @@ private fun SettingsContent(
|
||||
label = { t -> t.name},
|
||||
selectedValue = uiState.exportFormat
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(stringResource(R.string.settings_section_network), style = MaterialTheme.typography.titleLarge)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Server configuration
|
||||
Column {
|
||||
Text("PC 服务器设置", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
var hostInput by remember { mutableStateOf(uiState.serverHost ?: "") }
|
||||
LaunchedEffect(uiState.serverHost) {
|
||||
hostInput = uiState.serverHost ?: ""
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = hostInput,
|
||||
onValueChange = {
|
||||
hostInput = it
|
||||
onServerHostChanged(it.ifEmpty { null })
|
||||
},
|
||||
label = { Text("主机地址") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
var portInput by remember { mutableStateOf(uiState.serverPort.toString()) }
|
||||
LaunchedEffect(uiState.serverPort) {
|
||||
portInput = uiState.serverPort.toString()
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = portInput,
|
||||
onValueChange = { newValue ->
|
||||
portInput = newValue
|
||||
newValue.toIntOrNull()?.let { onServerPortChanged(it) }
|
||||
},
|
||||
label = { Text("端口") },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 8.dp),
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
if (uiState.serverDisplayName != null) {
|
||||
Text(
|
||||
"已连接: ${uiState.serverDisplayName}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onScanNetworkHostsClick,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("扫描主机")
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onTestConnectionClick,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("测试连接")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Stream quality
|
||||
RadioButtonGroup(
|
||||
R.string.stream_quality,
|
||||
StreamQuality.entries,
|
||||
onClick = onStreamQualityChanged,
|
||||
label = { t -> when (t) {
|
||||
StreamQuality.LOW -> "低 (640p, 45%, 8-12fps)"
|
||||
StreamQuality.BALANCED -> "均衡 (960p, 60%, 6-10fps)"
|
||||
StreamQuality.HIGH -> "高 (1280p, 75%, 5-8fps)"
|
||||
} },
|
||||
selectedValue = uiState.streamQuality
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Post process mode
|
||||
RadioButtonGroup(
|
||||
R.string.post_process_mode,
|
||||
PostProcessMode.entries,
|
||||
onClick = onPostProcessModeChanged,
|
||||
label = { t -> when (t) {
|
||||
PostProcessMode.MARKDOWN -> "Markdown (MinerU)"
|
||||
PostProcessMode.OCRPDF -> "OCR PDF (OCRmyPDF)"
|
||||
} },
|
||||
selectedValue = uiState.postProcessMode
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Stream frame rate control
|
||||
Text("图传帧率", style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
StreamFrameRate.entries.forEach { rate ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onStreamFrameRateChanged(rate) }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = uiState.streamFrameRate == rate,
|
||||
onClick = null,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 0.dp)
|
||||
)
|
||||
val desc = when (rate) {
|
||||
StreamFrameRate.UNLIMITED -> "无限制(每帧都发)"
|
||||
StreamFrameRate.FPS_15 -> "15 fps(66ms 间隔)"
|
||||
StreamFrameRate.FPS_10 -> "10 fps(100ms 间隔)"
|
||||
StreamFrameRate.FPS_5 -> "5 fps(200ms 间隔)"
|
||||
}
|
||||
Text(desc, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// Auto download
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onAutoDownloadChanged(!uiState.autoDownloadProcessedResult) }
|
||||
.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text("自动下载处理结果")
|
||||
Checkbox(
|
||||
checked = uiState.autoDownloadProcessedResult,
|
||||
onCheckedChange = { onAutoDownloadChanged(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,6 +450,14 @@ fun SettingsScreenPreview(uiState: SettingsUiState) {
|
||||
onResetExportDirClick = {},
|
||||
onExportFormatChanged = {},
|
||||
onExportQualityChanged = {},
|
||||
onServerHostChanged = {},
|
||||
onServerPortChanged = {},
|
||||
onStreamQualityChanged = {},
|
||||
onPostProcessModeChanged = {},
|
||||
onAutoDownloadChanged = {},
|
||||
onStreamFrameRateChanged = {},
|
||||
onScanNetworkHostsClick = {},
|
||||
onTestConnectionClick = {},
|
||||
onBack = {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,15 @@ data class SettingsUiState(
|
||||
val exportDirName: String? = null,
|
||||
val exportFormat: ExportFormat = ExportFormat.PDF,
|
||||
val exportQuality: ExportQuality = ExportQuality.BALANCED,
|
||||
// Network collaboration settings
|
||||
val serverHost: String? = null,
|
||||
val serverPort: Int = 2026,
|
||||
val serverDisplayName: String? = null,
|
||||
val lastSelectedServiceId: String? = null,
|
||||
val streamQuality: StreamQuality = StreamQuality.BALANCED,
|
||||
val postProcessMode: PostProcessMode = PostProcessMode.OCRPDF,
|
||||
val autoDownloadProcessedResult: Boolean = false,
|
||||
val streamFrameRate: StreamFrameRate = StreamFrameRate.FPS_10,
|
||||
)
|
||||
|
||||
class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
@@ -47,13 +56,29 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
dirName,
|
||||
repo.exportFormat,
|
||||
repo.exportQuality,
|
||||
) { colorMode, uri, name, format, quality ->
|
||||
repo.serverHost,
|
||||
repo.serverPort,
|
||||
repo.serverDisplayName,
|
||||
repo.lastSelectedServiceId,
|
||||
repo.streamQuality,
|
||||
repo.postProcessMode,
|
||||
repo.autoDownloadProcessedResult,
|
||||
repo.streamFrameRate,
|
||||
) { values: Array<Any?> ->
|
||||
SettingsUiState(
|
||||
defaultColorMode = colorMode,
|
||||
exportDirUri = uri,
|
||||
exportDirName = name,
|
||||
exportFormat = format,
|
||||
exportQuality = quality,
|
||||
defaultColorMode = values[0] as DefaultColorMode,
|
||||
exportDirUri = values[1] as String?,
|
||||
exportDirName = values[2] as String?,
|
||||
exportFormat = values[3] as ExportFormat,
|
||||
exportQuality = values[4] as ExportQuality,
|
||||
serverHost = values[5] as String?,
|
||||
serverPort = values[6] as Int,
|
||||
serverDisplayName = values[7] as String?,
|
||||
lastSelectedServiceId = values[8] as String?,
|
||||
streamQuality = values[9] as StreamQuality,
|
||||
postProcessMode = values[10] as PostProcessMode,
|
||||
autoDownloadProcessedResult = values[11] as Boolean,
|
||||
streamFrameRate = values[12] as StreamFrameRate,
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
@@ -92,4 +117,52 @@ class SettingsViewModel(container: AppContainer) : ViewModel() {
|
||||
_dirName.value = uri?.let { repo.resolveExportDirName(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setServerHost(host: String?) {
|
||||
viewModelScope.launch {
|
||||
repo.setServerHost(host)
|
||||
}
|
||||
}
|
||||
|
||||
fun setServerPort(port: Int) {
|
||||
viewModelScope.launch {
|
||||
repo.setServerPort(port)
|
||||
}
|
||||
}
|
||||
|
||||
fun setServerDisplayName(name: String?) {
|
||||
viewModelScope.launch {
|
||||
repo.setServerDisplayName(name)
|
||||
}
|
||||
}
|
||||
|
||||
fun setLastSelectedServiceId(id: String?) {
|
||||
viewModelScope.launch {
|
||||
repo.setLastSelectedServiceId(id)
|
||||
}
|
||||
}
|
||||
|
||||
fun setStreamQuality(quality: StreamQuality) {
|
||||
viewModelScope.launch {
|
||||
repo.setStreamQuality(quality)
|
||||
}
|
||||
}
|
||||
|
||||
fun setPostProcessMode(mode: PostProcessMode) {
|
||||
viewModelScope.launch {
|
||||
repo.setPostProcessMode(mode)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAutoDownloadProcessedResult(enabled: Boolean) {
|
||||
viewModelScope.launch {
|
||||
repo.setAutoDownloadProcessedResult(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun setStreamFrameRate(rate: StreamFrameRate) {
|
||||
viewModelScope.launch {
|
||||
repo.setStreamFrameRate(rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 68 KiB |
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
<foreground android:drawable="@drawable/icon"/>
|
||||
<monochrome android:drawable="@drawable/icon" />
|
||||
</adaptive-icon>
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
|
||||
<foreground android:drawable="@drawable/icon"/>
|
||||
<monochrome android:drawable="@drawable/icon" />
|
||||
</adaptive-icon>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 1.7 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0CAD55</color>
|
||||
<color name="ic_launcher_background">#FFFFFF</color>
|
||||
</resources>
|
||||
@@ -63,6 +63,9 @@
|
||||
<string name="settings">Settings</string>
|
||||
<string name="settings_section_scan">Scan</string>
|
||||
<string name="settings_section_export">Export</string>
|
||||
<string name="settings_section_network">Network Collaboration</string>
|
||||
<string name="stream_quality">Stream Quality</string>
|
||||
<string name="post_process_mode">Post Process Mode</string>
|
||||
<string name="share">Share</string>
|
||||
<string name="share_document">Share document</string>
|
||||
<string name="storage_permission_denied">Cannot save file: permission was denied</string>
|
||||
|
||||
4
app/src/main/res/xml/network_security_config.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="true" />
|
||||
</network-security-config>
|
||||
@@ -23,6 +23,7 @@ kotlinSerialization = "1.10.0"
|
||||
reorderable = "3.0.0"
|
||||
jetbrainsKotlinJvm = "2.3.10"
|
||||
coroutines-test = "1.10.2"
|
||||
okhttp = "4.12.0"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
@@ -63,6 +64,7 @@ kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-cor
|
||||
|
||||
|
||||
assertj = { group="org.assertj", name="assertj-core", version.ref = "assertj" }
|
||||
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
|
||||
15
pc-server/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# PC Server for FairScan real-time camera streaming
|
||||
#
|
||||
# This is a minimal test server that:
|
||||
# - Receives WebSocket frames from the Android app
|
||||
# - Displays them in a browser
|
||||
# - Provides GET /health for connection testing
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python main.py
|
||||
```
|
||||
|
||||
Open http://localhost:2026 in a browser to see the stream.
|
||||
800
pc-server/main.py
Normal file
@@ -0,0 +1,800 @@
|
||||
"""
|
||||
FairScan PC Server — Streaming, PDF upload & real MinerU task processing.
|
||||
|
||||
Endpoints:
|
||||
Streaming:
|
||||
GET /health → Health check (used by Android for connection test)
|
||||
WS /stream → WebSocket endpoint for receiving JPEG frames
|
||||
GET / → Web page showing the live stream
|
||||
|
||||
Upload & Tasks:
|
||||
POST /upload/pdf → Upload a PDF file, returns fileId
|
||||
POST /tasks/process → Create a MinerU processing task (ocrpdf / markdown)
|
||||
GET /tasks/{taskId} → Query task status (queued/processing/completed/failed)
|
||||
GET /tasks/{taskId}/artifacts → List result files for a completed task
|
||||
GET /artifacts/{artifactId}/download → Download a result file
|
||||
GET /files/{fileId}/download → Download an uploaded file
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# 国内网络环境无法访问 huggingface.co,强制使用本地缓存模型
|
||||
os.environ["HF_HUB_OFFLINE"] = "1"
|
||||
# Tesseract OCR 语言包路径(OCRmyPDF 需要,从 conda 环境自动获取)
|
||||
_tessdata = Path(os.environ.get("CONDA_PREFIX", "")) / "Library" / "share" / "tessdata"
|
||||
if _tessdata.exists():
|
||||
os.environ["TESSDATA_PREFIX"] = str(_tessdata)
|
||||
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||||
|
||||
# ── MinerU & OCRmyPDF integration ────────────────────────────────────────────
|
||||
|
||||
from mineru.cli.common import aio_do_parse, read_fn
|
||||
import ocrmypdf
|
||||
from loguru import logger
|
||||
|
||||
app = FastAPI(title="FairScan PC Server")
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
UPLOAD_DIR = Path("./uploads")
|
||||
TASKS_DIR = Path("./tasks")
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
TASKS_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
# ── In-memory state (streaming) ──────────────────────────────────────────────
|
||||
|
||||
latest_frame: bytes | None = None
|
||||
frame_timestamp: float = 0.0
|
||||
connected_clients: set[WebSocket] = set()
|
||||
stream_stats: dict = {"frames_received": 0, "bytes_received": 0, "started_at": None}
|
||||
|
||||
|
||||
# ── HTML page with live stream viewer ────────────────────────────────────────
|
||||
|
||||
STREAM_PAGE = """\
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FairScan Stream</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.nav-bar {
|
||||
display: flex; align-items: center; gap: 16px; margin-bottom: 20px;
|
||||
width: 100%; max-width: 900px;
|
||||
}
|
||||
.nav-bar h1 { font-size: 1.4rem; margin: 0; opacity: 0.9; }
|
||||
.nav-bar .nav-links { margin-left: auto; display: flex; gap: 12px; }
|
||||
.nav-bar .nav-links a {
|
||||
color: #7c8dff; text-decoration: none; padding: 6px 14px;
|
||||
border: 1px solid #7c8dff44; border-radius: 6px; font-size: 0.85rem;
|
||||
}
|
||||
.nav-bar .nav-links a:hover { background: #7c8dff22; }
|
||||
.nav-bar .nav-links a.active { background: #7c8dff22; border-color: #7c8dff; }
|
||||
h1 { margin-bottom: 16px; font-size: 1.4rem; opacity: 0.9; }
|
||||
#stream {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 30px rgba(0,0,0,0.5);
|
||||
background: #000;
|
||||
display: block;
|
||||
}
|
||||
#status {
|
||||
margin-top: 12px;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
#no-frame {
|
||||
width: 640px; height: 480px;
|
||||
background: #16213e;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav-bar">
|
||||
<h1>📷 FairScan Live Stream</h1>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="active">📷 图传预览</a>
|
||||
<a href="/dashboard">📊 管理面板</a>
|
||||
</div>
|
||||
</div>
|
||||
<img id="stream" src="" alt="Waiting for stream..." style="display:none">
|
||||
<div id="no-frame">Waiting for stream...</div>
|
||||
<div id="status">Not connected</div>
|
||||
<script>
|
||||
const img = document.getElementById('stream');
|
||||
const noFrame = document.getElementById('no-frame');
|
||||
const status = document.getElementById('status');
|
||||
let ws = null;
|
||||
|
||||
function connect() {
|
||||
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const url = protocol + '//' + location.host + '/stream';
|
||||
ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
status.textContent = 'Connected';
|
||||
status.style.color = '#4CAF50';
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const blob = new Blob([event.data], { type: 'image/jpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
img.src = url;
|
||||
img.style.display = 'block';
|
||||
noFrame.style.display = 'none';
|
||||
status.textContent = 'Receiving frames';
|
||||
status.style.color = '#4CAF50';
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
status.textContent = 'Disconnected - reconnecting in 2s...';
|
||||
status.style.color = '#E53935';
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
|
||||
ws.onerror = () => { ws.close(); };
|
||||
}
|
||||
|
||||
connect();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
# ── Routes: Streaming ────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
"""Health check endpoint used by Android for connection testing."""
|
||||
return JSONResponse({
|
||||
"status": "ok",
|
||||
"name": "FairScan-PC",
|
||||
"features": ["stream", "upload", "tasks"],
|
||||
"streamStats": {
|
||||
"framesReceived": stream_stats["frames_received"],
|
||||
"uptime": (
|
||||
time.time() - stream_stats["started_at"]
|
||||
if stream_stats["started_at"] else 0
|
||||
),
|
||||
},
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def index():
|
||||
"""Serve the live stream viewer page."""
|
||||
return HTMLResponse(STREAM_PAGE)
|
||||
|
||||
|
||||
@app.websocket("/stream")
|
||||
async def stream_endpoint(ws: WebSocket):
|
||||
"""WebSocket endpoint that receives JPEG frames from the Android app."""
|
||||
await ws.accept()
|
||||
connected_clients.add(ws)
|
||||
if stream_stats["started_at"] is None:
|
||||
stream_stats["started_at"] = time.time()
|
||||
|
||||
try:
|
||||
frame_count = 0
|
||||
while True:
|
||||
data = await ws.receive_bytes()
|
||||
global latest_frame, frame_timestamp
|
||||
latest_frame = data
|
||||
frame_timestamp = time.time()
|
||||
stream_stats["frames_received"] += 1
|
||||
stream_stats["bytes_received"] += len(data)
|
||||
frame_count += 1
|
||||
if frame_count % 30 == 1:
|
||||
print(f"[Stream] Received frame #{stream_stats['frames_received']} ({len(data)} bytes)")
|
||||
# Broadcast to all browser clients
|
||||
for client in connected_clients:
|
||||
if client is not ws:
|
||||
try:
|
||||
await client.send_bytes(data)
|
||||
except Exception:
|
||||
connected_clients.discard(client)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
connected_clients.discard(ws)
|
||||
|
||||
|
||||
# ── Routes: Upload & Tasks ──────────────────────────────────────────────────
|
||||
|
||||
files_db: dict[str, dict] = {} # fileId -> {fileId, fileName, sizeBytes, uploadPath, createdAt}
|
||||
|
||||
|
||||
@app.post("/upload/pdf", status_code=201)
|
||||
async def upload_pdf(file: UploadFile = File(...)):
|
||||
"""Upload a PDF file to the PC (no processing).
|
||||
|
||||
Stores the file in ./uploads/ and returns a fileId for later use.
|
||||
Processing is a separate step via POST /tasks/process.
|
||||
"""
|
||||
if not file.filename or not file.filename.lower().endswith(".pdf"):
|
||||
raise HTTPException(status_code=400, detail="Only PDF files are accepted")
|
||||
|
||||
file_id = str(uuid.uuid4())
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
safe_name = file.filename.replace("..", "").replace("/", "_")
|
||||
|
||||
# Save the uploaded PDF
|
||||
upload_path = UPLOAD_DIR / f"{file_id}_{safe_name}"
|
||||
content = await file.read()
|
||||
upload_path.write_bytes(content)
|
||||
|
||||
# Store file record (pure upload, no task/processing)
|
||||
file_record = {
|
||||
"fileId": file_id,
|
||||
"fileName": safe_name,
|
||||
"mimeType": "application/pdf",
|
||||
"sizeBytes": len(content),
|
||||
"uploadPath": str(upload_path),
|
||||
"createdAt": timestamp,
|
||||
}
|
||||
files_db[file_id] = file_record
|
||||
|
||||
print(f"[Upload] Received {safe_name} ({len(content)} bytes) -> file {file_id}")
|
||||
return JSONResponse({
|
||||
"fileId": file_id,
|
||||
"fileName": safe_name,
|
||||
"mimeType": "application/pdf",
|
||||
"sizeBytes": len(content),
|
||||
})
|
||||
|
||||
|
||||
@app.post("/tasks/process", status_code=202)
|
||||
async def create_task(body: dict):
|
||||
"""Create a processing task for an uploaded PDF.
|
||||
|
||||
Request body: {"fileId": "...", "processType": "ocrpdf"|"markdown"}
|
||||
"""
|
||||
file_id = body.get("fileId", "")
|
||||
process_type = body.get("processType", "ocrpdf").lower()
|
||||
|
||||
if not file_id:
|
||||
raise HTTPException(status_code=400, detail="fileId is required")
|
||||
if process_type not in ("ocrpdf", "markdown"):
|
||||
raise HTTPException(status_code=400, detail="processType must be 'ocrpdf' or 'markdown'")
|
||||
|
||||
# Look up the uploaded file
|
||||
file_record = files_db.get(file_id)
|
||||
if file_record is None:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
task_id = str(uuid.uuid4())
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
|
||||
task = {
|
||||
"taskId": task_id,
|
||||
"fileId": file_id,
|
||||
"status": "queued",
|
||||
"progress": 0,
|
||||
"processType": process_type,
|
||||
"fileName": file_record["fileName"],
|
||||
"createdAt": timestamp,
|
||||
"updatedAt": timestamp,
|
||||
"uploadPath": file_record["uploadPath"],
|
||||
"message": f"Task created (processType={process_type})",
|
||||
}
|
||||
tasks_db[task_id] = task
|
||||
|
||||
# Start MinerU processing in background
|
||||
asyncio.create_task(process_with_mineru(task_id))
|
||||
|
||||
print(f"[Tasks] Created task {task_id} for file {file_id} (processType={process_type})")
|
||||
return JSONResponse({
|
||||
"taskId": task_id,
|
||||
"status": "queued",
|
||||
"processType": process_type,
|
||||
"fileId": file_id,
|
||||
})
|
||||
|
||||
|
||||
@app.get("/tasks/{task_id}")
|
||||
async def get_task_status(task_id: str):
|
||||
"""Get the current status of a processing task."""
|
||||
task = tasks_db.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
return JSONResponse({
|
||||
"taskId": task["taskId"],
|
||||
"fileId": task.get("fileId", ""),
|
||||
"status": task["status"],
|
||||
"progress": task["progress"],
|
||||
"processType": task.get("processType", ""),
|
||||
"fileName": task["fileName"],
|
||||
"createdAt": task["createdAt"],
|
||||
"message": task.get("message", ""),
|
||||
})
|
||||
|
||||
|
||||
@app.get("/tasks/{task_id}/artifacts")
|
||||
async def list_artifacts(task_id: str):
|
||||
"""List result files for a completed task."""
|
||||
task = tasks_db.get(task_id)
|
||||
if task is None:
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
artifacts = artifacts_db.get(task_id, [])
|
||||
result = []
|
||||
for art in artifacts:
|
||||
result.append({
|
||||
"id": art["artifactId"],
|
||||
"artifactId": art["artifactId"],
|
||||
"fileName": art["fileName"],
|
||||
"fileSize": art["fileSize"],
|
||||
"fileType": art["fileType"],
|
||||
})
|
||||
return JSONResponse(result)
|
||||
|
||||
|
||||
@app.get("/artifacts/{artifact_id}/download")
|
||||
async def download_artifact(artifact_id: str):
|
||||
"""Download a processed artifact file."""
|
||||
art = artifacts_map.get(artifact_id)
|
||||
if art is None:
|
||||
raise HTTPException(status_code=404, detail="Artifact not found")
|
||||
|
||||
file_path = Path(art["filePath"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="Artifact file not found on disk")
|
||||
|
||||
file_type = art["fileType"]
|
||||
if file_type == "pdf":
|
||||
media_type = "application/pdf"
|
||||
elif file_type == "zip":
|
||||
media_type = "application/zip"
|
||||
else:
|
||||
media_type = "text/markdown"
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=art["fileName"],
|
||||
media_type=media_type,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/files/{file_id}/download")
|
||||
async def download_uploaded_file(file_id: str):
|
||||
"""Download an uploaded (unprocessed) PDF file."""
|
||||
file_record = files_db.get(file_id)
|
||||
if file_record is None:
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
file_path = Path(file_record["uploadPath"])
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=file_record["fileName"],
|
||||
media_type="application/pdf",
|
||||
)
|
||||
|
||||
|
||||
# ── Dashboard page ───────────────────────────────────────────────────────────
|
||||
|
||||
DASHBOARD_PAGE = """\
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>FairScan Dashboard</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
color: #eee;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
padding: 24px;
|
||||
}
|
||||
.header {
|
||||
display: flex; align-items: center; gap: 16px; margin-bottom: 24px;
|
||||
}
|
||||
.header h1 { font-size: 1.5rem; }
|
||||
.header .nav-links { margin-left: auto; display: flex; gap: 12px; }
|
||||
.header .nav-links a {
|
||||
color: #7c8dff; text-decoration: none; padding: 6px 14px;
|
||||
border: 1px solid #7c8dff44; border-radius: 6px; font-size: 0.85rem;
|
||||
}
|
||||
.header .nav-links a:hover { background: #7c8dff22; }
|
||||
.stats-row {
|
||||
display: flex; gap: 16px; margin-bottom: 24px; flex-wrap: wrap;
|
||||
}
|
||||
.stat-card {
|
||||
background: #16213e; border-radius: 10px; padding: 16px 24px; flex: 1;
|
||||
min-width: 120px; text-align: center;
|
||||
}
|
||||
.stat-card .num { font-size: 1.8rem; font-weight: 700; }
|
||||
.stat-card .label { font-size: 0.75rem; opacity: 0.6; margin-top: 4px; }
|
||||
.stat-card.queued .num { color: #ffa726; }
|
||||
.stat-card.processing .num { color: #42a5f5; }
|
||||
.stat-card.completed .num { color: #66bb6a; }
|
||||
.stat-card.failed .num { color: #ef5350; }
|
||||
.section-title { font-size: 1.1rem; margin-bottom: 12px; opacity: 0.8; }
|
||||
.task-table {
|
||||
width: 100%; border-collapse: collapse; margin-bottom: 24px;
|
||||
background: #16213e; border-radius: 10px; overflow: hidden;
|
||||
}
|
||||
.task-table th {
|
||||
text-align: left; padding: 12px 16px; font-size: 0.75rem;
|
||||
text-transform: uppercase; letter-spacing: 0.5px; opacity: 0.5;
|
||||
border-bottom: 1px solid #ffffff11;
|
||||
}
|
||||
.task-table td {
|
||||
padding: 12px 16px; font-size: 0.85rem; border-bottom: 1px solid #ffffff08;
|
||||
}
|
||||
.task-table tr:hover td { background: #ffffff06; }
|
||||
.badge {
|
||||
display: inline-block; padding: 2px 10px; border-radius: 10px;
|
||||
font-size: 0.75rem; font-weight: 600;
|
||||
}
|
||||
.badge.queued { background: #ffa72633; color: #ffa726; }
|
||||
.badge.processing { background: #42a5f533; color: #42a5f5; }
|
||||
.badge.completed { background: #66bb6a33; color: #66bb6a; }
|
||||
.badge.failed { background: #ef535033; color: #ef5350; }
|
||||
.progress-bar {
|
||||
width: 120px; height: 6px; background: #ffffff15; border-radius: 3px; overflow: hidden;
|
||||
}
|
||||
.progress-bar .fill {
|
||||
height: 100%; border-radius: 3px; transition: width 0.5s ease;
|
||||
background: linear-gradient(90deg, #42a5f5, #66bb6a);
|
||||
}
|
||||
.download-btn {
|
||||
color: #7c8dff; text-decoration: none; font-size: 0.8rem;
|
||||
padding: 4px 10px; border: 1px solid #7c8dff44; border-radius: 4px;
|
||||
}
|
||||
.download-btn:hover { background: #7c8dff22; }
|
||||
.empty-state { opacity: 0.4; text-align: center; padding: 40px; font-size: 0.9rem; }
|
||||
.file-name { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📊 FairScan Dashboard</h1>
|
||||
<div class="nav-links">
|
||||
<a href="/">📷 图传预览</a>
|
||||
<a href="/dashboard" style="background:#7c8dff22;border-color:#7c8dff;">📊 管理面板</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats-row" id="stats"></div>
|
||||
|
||||
<div class="section-title">📄 已上传的文件</div>
|
||||
<table class="task-table">
|
||||
<thead><tr>
|
||||
<th>文件名</th><th>文件 ID</th><th>大小</th><th>时间</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody id="file-list"></tbody>
|
||||
</table>
|
||||
|
||||
<div class="section-title">⚙️ 处理任务</div>
|
||||
<table class="task-table">
|
||||
<thead><tr>
|
||||
<th>文件名</th><th>任务 ID</th><th>状态</th><th>进度</th><th>处理类型</th><th>时间</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody id="task-list"></tbody>
|
||||
</table>
|
||||
|
||||
<script>
|
||||
function fmtSize(bytes) {
|
||||
if (!bytes) return '-';
|
||||
if (bytes < 1024) return bytes + 'B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + 'KB';
|
||||
return (bytes / 1048576).toFixed(1) + 'MB';
|
||||
}
|
||||
function fmtTime(iso) {
|
||||
return iso ? iso.replace('T', ' ').substring(0, 19) : '-';
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const resp = await fetch('/api/dashboard');
|
||||
const data = await resp.json();
|
||||
|
||||
// Stats
|
||||
const s = data.stats;
|
||||
document.getElementById('stats').innerHTML =
|
||||
`<div class="stat-card"><div class="num">${data.files.length}</div><div class="label">已上传</div></div>
|
||||
<div class="stat-card"><div class="num">${s.total}</div><div class="label">处理任务</div></div>
|
||||
<div class="stat-card queued"><div class="num">${s.queued}</div><div class="label">排队中</div></div>
|
||||
<div class="stat-card processing"><div class="num">${s.processing}</div><div class="label">处理中</div></div>
|
||||
<div class="stat-card completed"><div class="num">${s.completed}</div><div class="label">已完成</div></div>`;
|
||||
|
||||
// Files
|
||||
const fbody = document.getElementById('file-list');
|
||||
if (data.files.length === 0) {
|
||||
fbody.innerHTML = '<tr><td colspan="5"><div class="empty-state">暂无上传文件</div></td></tr>';
|
||||
} else {
|
||||
fbody.innerHTML = data.files.map(f =>
|
||||
`<tr>
|
||||
<td><div class="file-name" title="${f.fileName}">${f.fileName}</div></td>
|
||||
<td style="font-family:monospace;font-size:0.75rem;opacity:0.6">${f.fileId.substring(0, 8)}...</td>
|
||||
<td>${fmtSize(f.sizeBytes)}</td>
|
||||
<td style="font-size:0.75rem;opacity:0.5">${fmtTime(f.createdAt)}</td>
|
||||
<td><a class="download-btn" href="/files/${f.fileId}/download">⬇ 下载</a></td>
|
||||
</tr>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Tasks
|
||||
const tbody = document.getElementById('task-list');
|
||||
if (data.tasks.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7"><div class="empty-state">暂无处理任务</div></td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = data.tasks.map(t => {
|
||||
const badge = `<span class="badge ${t.status}">${t.status}</span>`;
|
||||
const bar = t.status === 'processing'
|
||||
? `<div class="progress-bar"><div class="fill" style="width:${t.progress}%"></div></div>`
|
||||
: t.status === 'completed' ? '✅ 完成' : `${t.progress}%`;
|
||||
const actions = t.artifacts && t.artifacts.length > 0
|
||||
? t.artifacts.map(a => `<a class="download-btn" href="/artifacts/${a.id}/download">⬇ ${a.fileName}</a>`).join(' ')
|
||||
: t.status === 'completed' ? '✅' : '-';
|
||||
return `<tr>
|
||||
<td><div class="file-name" title="${t.fileName}">${t.fileName}</div></td>
|
||||
<td style="font-family:monospace;font-size:0.75rem;opacity:0.6">${t.taskId.substring(0, 8)}...</td>
|
||||
<td>${badge}</td>
|
||||
<td>${bar}</td>
|
||||
<td>${t.processType || '-'}</td>
|
||||
<td style="font-size:0.75rem;opacity:0.5">${fmtTime(t.createdAt)}</td>
|
||||
<td>${actions}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
document.getElementById('task-list').innerHTML =
|
||||
'<tr><td colspan="7"><div class="empty-state">⚠️ 连接服务器失败: ' + e.message + '</div></td></tr>';
|
||||
}
|
||||
}
|
||||
refresh();
|
||||
setInterval(refresh, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@app.get("/dashboard")
|
||||
async def dashboard():
|
||||
"""Serve the task management dashboard page."""
|
||||
return HTMLResponse(DASHBOARD_PAGE)
|
||||
|
||||
|
||||
@app.get("/api/dashboard")
|
||||
async def dashboard_api():
|
||||
"""JSON endpoint providing dashboard data (files + tasks + stats)."""
|
||||
# List uploaded files
|
||||
files_list = []
|
||||
for fid, f_rec in files_db.items():
|
||||
files_list.append({
|
||||
"fileId": fid,
|
||||
"fileName": f_rec.get("fileName", ""),
|
||||
"sizeBytes": f_rec.get("sizeBytes", 0),
|
||||
"createdAt": f_rec.get("createdAt", ""),
|
||||
})
|
||||
files_list.sort(key=lambda f: f.get("createdAt", ""), reverse=True)
|
||||
|
||||
# List tasks
|
||||
tasks_list = []
|
||||
for tid, task in tasks_db.items():
|
||||
task_artifacts = artifacts_db.get(tid, [])
|
||||
artifacts_info = [
|
||||
{"id": a["artifactId"], "fileName": a["fileName"]}
|
||||
for a in task_artifacts
|
||||
]
|
||||
tasks_list.append({
|
||||
"taskId": tid,
|
||||
"fileId": task.get("fileId", ""),
|
||||
"fileName": task.get("fileName", ""),
|
||||
"status": task["status"],
|
||||
"progress": task["progress"],
|
||||
"processType": task.get("processType", ""),
|
||||
"createdAt": task.get("createdAt", ""),
|
||||
"message": task.get("message", ""),
|
||||
"artifacts": artifacts_info,
|
||||
})
|
||||
tasks_list.sort(key=lambda t: t.get("createdAt", ""), reverse=True)
|
||||
|
||||
total = len(tasks_list)
|
||||
queued = sum(1 for t in tasks_list if t["status"] == "queued")
|
||||
processing = sum(1 for t in tasks_list if t["status"] == "processing")
|
||||
completed = sum(1 for t in tasks_list if t["status"] == "completed")
|
||||
failed = sum(1 for t in tasks_list if t["status"] == "failed")
|
||||
|
||||
return JSONResponse({
|
||||
"stats": {"total": total, "queued": queued, "processing": processing, "completed": completed, "failed": failed},
|
||||
"files": files_list,
|
||||
"tasks": tasks_list,
|
||||
})
|
||||
|
||||
|
||||
# ── In-memory databases ──────────────────────────────────────────────────────
|
||||
|
||||
tasks_db: dict[str, dict] = {}
|
||||
artifacts_db: dict[str, list[dict]] = {}
|
||||
artifacts_map: dict[str, dict] = {}
|
||||
|
||||
|
||||
async def process_with_mineru(task_id: str):
|
||||
"""Process a PDF using real MinerU pipeline (replaces simulate_processing)."""
|
||||
task = tasks_db.get(task_id)
|
||||
if task is None:
|
||||
return
|
||||
|
||||
process_type = task.get("processType", "ocrpdf")
|
||||
upload_path_src = task.get("uploadPath")
|
||||
file_name = task.get("fileName", "document.pdf")
|
||||
base_name = Path(file_name).stem
|
||||
lang = task.get("options", {}).get("lang", "ch")
|
||||
|
||||
if not upload_path_src or not Path(upload_path_src).exists():
|
||||
task["status"] = "failed"
|
||||
task["message"] = "Uploaded file not found on disk"
|
||||
logger.error(f"[MinerU] Task {task_id}: file not found at {upload_path_src}")
|
||||
return
|
||||
|
||||
task["status"] = "processing"
|
||||
task["progress"] = 15
|
||||
task["updatedAt"] = datetime.utcnow().isoformat()
|
||||
task["message"] = f"MinerU pipeline started (backend=pipeline, processType={process_type})"
|
||||
logger.info(f"[MinerU] Task {task_id}: starting {process_type} on {file_name}")
|
||||
|
||||
# Prepare output directory
|
||||
output_dir = TASKS_DIR / task_id
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
pdf_bytes = read_fn(upload_path_src)
|
||||
|
||||
if process_type == "markdown":
|
||||
await aio_do_parse(
|
||||
output_dir=str(output_dir),
|
||||
pdf_file_names=[base_name],
|
||||
pdf_bytes_list=[pdf_bytes],
|
||||
p_lang_list=[lang],
|
||||
backend="pipeline",
|
||||
parse_method="auto",
|
||||
f_dump_md=True,
|
||||
f_dump_middle_json=False,
|
||||
f_dump_model_output=False,
|
||||
f_dump_orig_pdf=False,
|
||||
f_dump_content_list=False,
|
||||
f_draw_layout_bbox=False,
|
||||
f_draw_span_bbox=False,
|
||||
)
|
||||
# MinerU output: {output_dir}/{base_name}/auto/{base_name}.md
|
||||
md_dir = output_dir / base_name / "auto"
|
||||
md_path = md_dir / f"{base_name}.md"
|
||||
images_dir = md_dir / "images"
|
||||
|
||||
if md_path.exists():
|
||||
artifacts_list = []
|
||||
|
||||
# Register the .md artifact
|
||||
md_art_id = str(uuid.uuid4())
|
||||
md_artifact = {
|
||||
"artifactId": md_art_id,
|
||||
"fileName": f"{base_name}.md",
|
||||
"fileSize": md_path.stat().st_size,
|
||||
"fileType": "md",
|
||||
"filePath": str(md_path),
|
||||
}
|
||||
artifacts_list.append(md_artifact)
|
||||
artifacts_map[md_art_id] = md_artifact
|
||||
|
||||
# If images directory exists and has files, create a ZIP
|
||||
if images_dir.exists() and any(images_dir.iterdir()):
|
||||
zip_path = md_dir / f"{base_name}_result.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.write(md_path, md_path.name)
|
||||
for img_file in images_dir.rglob("*"):
|
||||
if img_file.is_file():
|
||||
arcname = f"images/{img_file.relative_to(images_dir)}"
|
||||
zf.write(img_file, arcname)
|
||||
zip_art_id = str(uuid.uuid4())
|
||||
zip_artifact = {
|
||||
"artifactId": zip_art_id,
|
||||
"fileName": f"{base_name}_result.zip",
|
||||
"fileSize": zip_path.stat().st_size,
|
||||
"fileType": "zip",
|
||||
"filePath": str(zip_path),
|
||||
}
|
||||
artifacts_list.append(zip_artifact)
|
||||
artifacts_map[zip_art_id] = zip_artifact
|
||||
logger.info(f"[MinerU] Task {task_id}: ZIP created -> {zip_path} ({zip_path.stat().st_size} bytes)")
|
||||
|
||||
artifacts_db[task_id] = artifacts_list
|
||||
task["status"] = "completed"
|
||||
task["progress"] = 100
|
||||
task["message"] = f"MinerU Markdown completed ({md_path.stat().st_size} bytes)"
|
||||
logger.info(f"[MinerU] Task {task_id}: markdown completed -> {md_path}")
|
||||
else:
|
||||
task["status"] = "failed"
|
||||
task["message"] = "MinerU did not produce .md output"
|
||||
logger.error(f"[MinerU] Task {task_id}: no .md output at {md_path}")
|
||||
|
||||
else: # ocrpdf — use OCRmyPDF for searchable dual-layer PDF
|
||||
ocr_lang = {"ch": "chi_sim", "en": "eng", "japan": "jpn", "korean": "kor"}.get(lang, "chi_sim")
|
||||
ocr_output = output_dir / f"{base_name}_ocr.pdf"
|
||||
|
||||
await asyncio.to_thread(
|
||||
ocrmypdf.ocr,
|
||||
upload_path_src,
|
||||
str(ocr_output),
|
||||
language=ocr_lang,
|
||||
output_type="pdf",
|
||||
skip_text=True,
|
||||
deskew=True,
|
||||
optimize=0, # skip JBIG2 optimization (pikepdf compat)
|
||||
)
|
||||
|
||||
if ocr_output.exists():
|
||||
art_id = str(uuid.uuid4())
|
||||
artifacts_db[task_id] = [{
|
||||
"artifactId": art_id,
|
||||
"fileName": f"{base_name}_ocr.pdf",
|
||||
"fileSize": ocr_output.stat().st_size,
|
||||
"fileType": "pdf",
|
||||
"filePath": str(ocr_output),
|
||||
}]
|
||||
artifacts_map[art_id] = artifacts_db[task_id][0]
|
||||
task["status"] = "completed"
|
||||
task["progress"] = 100
|
||||
task["message"] = f"OCRmyPDF completed ({ocr_output.stat().st_size} bytes)"
|
||||
logger.info(f"[OCRmyPDF] Task {task_id}: ocrpdf completed -> {ocr_output}")
|
||||
else:
|
||||
task["status"] = "failed"
|
||||
task["message"] = "OCRmyPDF did not produce output"
|
||||
|
||||
except Exception as e:
|
||||
task["status"] = "failed"
|
||||
task["message"] = f"MinerU error: {e}"
|
||||
task["progress"] = 0
|
||||
logger.error(f"[MinerU] Task {task_id}: exception - {e}")
|
||||
|
||||
task["updatedAt"] = datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
# ── Entry point ──────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
port = 2026
|
||||
print(f"🚀 FairScan PC Server starting on http://0.0.0.0:{port}")
|
||||
print(f" Stream: http://localhost:{port}")
|
||||
print(f" Dashboard: http://localhost:{port}/dashboard")
|
||||
print(f" Health: http://localhost:{port}/health")
|
||||
print(f" Upload: POST http://localhost:{port}/upload/pdf")
|
||||
print(f" Tasks: POST http://localhost:{port}/tasks/process")
|
||||
uvicorn.run(app, host="0.0.0.0", port=port, log_level="info")
|
||||
4
pc-server/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
websockets>=12.0
|
||||
Pillow>=10.0.0
|
||||
195
requirements/FIXES_SUMMARY.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# UI 扩展修复总结
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
### 1. SettingsRepository.kt
|
||||
**变更**:添加了网络协作相关的配置项和枚举类型
|
||||
|
||||
**新增内容**:
|
||||
- 7个新的 `stringPreferencesKey`:
|
||||
- `SERVER_HOST` - PC主机地址
|
||||
- `SERVER_PORT` - PC端口
|
||||
- `SERVER_DISPLAY_NAME` - PC显示名称
|
||||
- `LAST_SELECTED_SERVICE_ID` - 上次选择的服务ID
|
||||
- `STREAM_QUALITY` - 图传质量
|
||||
- `POST_PROCESS_MODE` - 后处理模式
|
||||
- `AUTO_DOWNLOAD_PROCESSED_RESULT` - 自动下载处理结果开关
|
||||
|
||||
- 7个新的 Flow 属性用于读取这些配置
|
||||
|
||||
- 8个新的 suspend fun setter 方法:
|
||||
- `setServerHost()`
|
||||
- `setServerPort()`
|
||||
- `setServerDisplayName()`
|
||||
- `setLastSelectedServiceId()`
|
||||
- `setStreamQuality()`
|
||||
- `setPostProcessMode()`
|
||||
- `setAutoDownloadProcessedResult()`
|
||||
|
||||
- 2个新的 enum 类型:
|
||||
- `StreamQuality(LOW, BALANCED, HIGH)` - 图传质量档位
|
||||
- `PostProcessMode(MARKDOWN, OCRPDF)` - 后处理模式
|
||||
|
||||
**修复**:修复了第231行缺少类闭合括号的问题
|
||||
|
||||
---
|
||||
|
||||
### 2. SettingsViewModel.kt
|
||||
**变更**:扩展了 UI 状态数据类和 combine flow
|
||||
|
||||
**新增内容**:
|
||||
- 扩展 `SettingsUiState` 数据类,添加了12个新字段:
|
||||
- `serverHost: String?`
|
||||
- `serverPort: Int`
|
||||
- `serverDisplayName: String?`
|
||||
- `lastSelectedServiceId: String?`
|
||||
- `streamQuality: StreamQuality`
|
||||
- `postProcessMode: PostProcessMode`
|
||||
- `autoDownloadProcessedResult: Boolean`
|
||||
|
||||
- 8个新的 ViewModel 方法与 Repository 的 setter 对接:
|
||||
- `setServerHost()`
|
||||
- `setServerPort()`
|
||||
- `setServerDisplayName()`
|
||||
- `setLastSelectedServiceId()`
|
||||
- `setStreamQuality()`
|
||||
- `setPostProcessMode()`
|
||||
- `setAutoDownloadProcessedResult()`
|
||||
|
||||
**修复**:
|
||||
- 使用 `Array<Any?>` 方式重写了 `combine()` 的 lambda,解决了12个参数类型推断失败的问题
|
||||
- 使用数组索引方式访问组合流的值,避免了 lambda 参数过多导致的编译错误
|
||||
|
||||
---
|
||||
|
||||
### 3. SettingsScreen.kt
|
||||
**变更**:添加了网络协作 UI 界面
|
||||
|
||||
**新增内容**:
|
||||
- 8个新的 lambda 参数到 `SettingsScreen()` 函数:
|
||||
- `onServerHostChanged`
|
||||
- `onServerPortChanged`
|
||||
- `onStreamQualityChanged`
|
||||
- `onPostProcessModeChanged`
|
||||
- `onAutoDownloadChanged`
|
||||
- `onScanNetworkHostsClick`
|
||||
- `onTestConnectionClick`
|
||||
|
||||
- 新增 "Network Collaboration" 部分 UI,包括:
|
||||
- PC 服务器配置(主机地址和端口输入框)
|
||||
- 当前连接状态显示
|
||||
- "扫描主机" 和 "测试连接" 按钮
|
||||
- 图传质量选择(低/均衡/高三档)
|
||||
- 后处理模式选择(Markdown/OCR PDF)
|
||||
- 自动下载处理结果开关
|
||||
|
||||
**修复**:
|
||||
- 第309行:添加了缺失的 `SettingsContent` 函数结束的闭合括号 `}`
|
||||
- 第230行:移除了 `keyboardType = KeyboardType.Number` 参数,改用基础的 `OutlinedTextField`,避免版本兼容性问题
|
||||
|
||||
---
|
||||
|
||||
### 4. strings.xml
|
||||
**变更**:添加了新的本地化字符串资源
|
||||
|
||||
**新增内容**:
|
||||
- `settings_section_network` - "Network Collaboration" 标题
|
||||
- `stream_quality` - "Stream Quality" 选项标题
|
||||
- `post_process_mode` - "Post Process Mode" 选项标题
|
||||
|
||||
这些资源用于 UI 显示,遵循现有的资源命名规范。
|
||||
|
||||
---
|
||||
|
||||
### 5. MainActivity.kt
|
||||
**变更**:更新了 `SettingsScreen` 的调用
|
||||
|
||||
**新增内容**:
|
||||
- 6个新的回调参数传递到 `SettingsScreen()`:
|
||||
- `onServerHostChanged = { host -> settingsViewModel.setServerHost(host) }`
|
||||
- `onServerPortChanged = { port -> settingsViewModel.setServerPort(port) }`
|
||||
- `onStreamQualityChanged = { quality -> settingsViewModel.setStreamQuality(quality) }`
|
||||
- `onPostProcessModeChanged = { mode -> settingsViewModel.setPostProcessMode(mode) }`
|
||||
- `onAutoDownloadChanged = { enabled -> settingsViewModel.setAutoDownloadProcessedResult(enabled) }`
|
||||
- `onScanNetworkHostsClick = { /* TODO */ }`
|
||||
- `onTestConnectionClick = { /* TODO */ }`
|
||||
|
||||
---
|
||||
|
||||
## 编译错误修复
|
||||
|
||||
### 原始错误
|
||||
1. **SettingsRepository.kt:231** - 缺少类闭合括号
|
||||
2. **SettingsScreen.kt:309** - 缺少函数结束括号
|
||||
3. **SettingsScreen.kt:230** - OutlinedTextField 的 keyboardType 参数不兼容
|
||||
4. **SettingsViewModel.kt:65** - combine 的 lambda 参数类型推断失败(12个参数过多)
|
||||
5. **MainActivity.kt:283** - SettingsScreen 调用缺少新参数
|
||||
|
||||
### 修复方案
|
||||
1. 添加了缺失的闭合括号
|
||||
2. 使用 Array<Any?> 方式重写 combine 的 lambda 参数,解决类型推断问题
|
||||
3. 移除了不兼容的 OutlinedTextField 参数
|
||||
4. 完整更新了所有调用点的参数传递
|
||||
|
||||
---
|
||||
|
||||
## 后续待办项目
|
||||
|
||||
这些是实现计划中的下一步任务:
|
||||
|
||||
### P0:局域网发现与基础连接
|
||||
- Task P0-2:实现局域网发现基础能力(NSD)
|
||||
- Task P0-3:补充网络基础设施(HTTP 客户端)
|
||||
|
||||
### P1:实时图传
|
||||
- Task P1-2:实现帧压缩与抽帧策略
|
||||
- Task P1-3:相机页接入图传控制
|
||||
|
||||
### P2:手机本地 PDF 上传
|
||||
- Task P2-1:实现 PDF 上传客户端
|
||||
|
||||
### P3:统一处理任务与结果下载
|
||||
- Task P3-1:实现统一任务接口客户端
|
||||
|
||||
### P4:体验优化
|
||||
- Task P4-1:发现结果去重与缓存
|
||||
|
||||
---
|
||||
|
||||
## 验证步骤
|
||||
|
||||
1. **编译验证**:
|
||||
```bash
|
||||
./gradlew clean build
|
||||
```
|
||||
|
||||
2. **单元测试**(如果有):
|
||||
```bash
|
||||
./gradlew testDebugUnitTest
|
||||
```
|
||||
|
||||
3. **运行应用**:
|
||||
- 打开应用
|
||||
- 进入设置页面
|
||||
- 验证新的"Network Collaboration"部分能正常显示
|
||||
- 验证所有输入框和按钮响应正常
|
||||
|
||||
---
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 为什么使用 Array<Any?> 方式处理 combine?
|
||||
Kotlin 的 combine 函数最多支持约 9 个参数的类型推断,超过这个数量会导致编译器无法自动推断 lambda 参数类型。通过使用数组方式,我们规避了这个限制,同时保持代码的可读性。
|
||||
|
||||
### 为什么移除了 keyboardType?
|
||||
某些 Jetpack Compose 版本中,`OutlinedTextField` 可能不支持 `keyboardType` 参数,或者参数名称/位置不同。通过使用基础的 `OutlinedTextField` API,我们确保代码与更多版本的 Compose 兼容。
|
||||
|
||||
---
|
||||
|
||||
## 文件修改统计
|
||||
|
||||
- 修改文件数:5 个
|
||||
- 新增代码行数:约 150 行
|
||||
- 修复编译错误:5 处
|
||||
- 新增功能点:20+ 个(包括新的参数、方法、UI 元素)
|
||||
|
||||
BIN
requirements/FairScan_reqirement.prg
Normal file
293
requirements/IMPLEMENTATION_COMPLETE.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# 实现完成报告(完整版 v3)
|
||||
|
||||
## 执行概览
|
||||
|
||||
✅ **状态**:Streaming (P1) + Upload/Task Pipeline (P2/P3) + MinerU 真实接入 + 任务管理面板 + Markdown ZIP 打包 已完成
|
||||
|
||||
**最近更新**:2026-06-04
|
||||
**范围**:MinerU 真实 markdown 处理、任务管理面板(手机端)、ZIP 打包下载、HF_HUB_OFFLINE 离线模式
|
||||
|
||||
---
|
||||
|
||||
## 已完成的工作
|
||||
|
||||
### P1:实时网络图传 ✅
|
||||
|
||||
#### 网络基础设施
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `network/ServerEndpoint.kt` | 服务端点模型(host/port/url/wsUrl) |
|
||||
| `network/NetworkInfoProvider.kt` | 本地 IP 获取 |
|
||||
| `network/stream/StreamState.kt` | 图传状态模型(Disconnected/Connecting/Connected/Error) |
|
||||
| `network/stream/StreamQualityPreset.kt` | 质量预设 ↔ StreamQuality 映射 |
|
||||
| `network/stream/FrameCompressor.kt` | JPEG 压缩 + 缩放 |
|
||||
| `network/stream/FrameDropController.kt` | 丢帧控制(AtomicBoolean + 时间间隔) |
|
||||
| `network/stream/OkHttpStreamClient.kt` | WebSocket 图传客户端(包含 StreamClient 接口) |
|
||||
|
||||
#### 相机页集成
|
||||
- `CameraViewModel`:添加 streamState、streamTargetHost、toggleStreaming()、sendStreamFrame()
|
||||
- `liveAnalysis()` 中嵌入图传帧发送(fire-and-forget,不影响 ML 分析)
|
||||
- `CameraScreen`:添加 StreamToggleButton(Cast 图标 + 状态颜色 + 主机显示)
|
||||
- 图传断连不影响正式扫描
|
||||
|
||||
#### PC 服务器
|
||||
- `pc-server/main.py`:FastAPI 服务,含 `/health`、`WS /stream`、Web 预览页面
|
||||
- 支持帧广播:接收手机帧并转发给浏览器客户端
|
||||
- 帧率统计和日志
|
||||
|
||||
#### 帧率控制
|
||||
- 添加 `StreamFrameRate` 枚举(UNLIMITED / FPS_15 / FPS_10 / FPS_5)
|
||||
- 设置页 RadioButton 选择
|
||||
- 无限制模式:minIntervalMs <= 0,仅以 isSending 状态控制
|
||||
|
||||
---
|
||||
|
||||
### P2/P3:PDF 上传与任务处理流水线 ✅
|
||||
|
||||
#### 上传与处理分离(最新重构)
|
||||
|
||||
遵循 `pc-api-spec.md` 接口规范,将上传和处理解耦为独立步骤:
|
||||
|
||||
**上传(纯传输)**:
|
||||
- `POST /upload/pdf` → 返回 `fileId`(201 Created)
|
||||
- 仅保存 PDF 到 `./uploads/`,不触发任何处理
|
||||
- PC 服务端使用独立 `files_db` 字典存储文件记录
|
||||
|
||||
**处理(任务创建)**:
|
||||
- `POST /tasks/process` → 基于 `fileId` + `processType` 创建任务(202 Accepted)
|
||||
- `processType` 可选值:`ocrpdf`、`markdown`
|
||||
- 异步模拟处理:queued → processing(10%→50%→90%) → completed
|
||||
- OCR PDF 模式:复制原始 PDF 作为处理结果(修复了之前空白 PDF 的问题)
|
||||
- Markdown 模式:生成示例 `.md` 文件
|
||||
|
||||
#### Android 端网络客户端
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `network/upload/PdfUploadClient.kt` | HTTP multipart POST 上传 PDF,返回 `(fileId, fileName, sizeBytes)` |
|
||||
| `network/tasks/TaskModels.kt` | 任务数据模型(TaskStatus / ArtifactInfo / ProcessTaskResult) |
|
||||
| `network/tasks/TaskClient.kt` | REST 客户端:`processPdf(fileId, processType)`、查询状态、产物列表、下载 |
|
||||
|
||||
#### 导出页三按钮 UI
|
||||
|
||||
Android 导出页新增三个独立操作按钮:
|
||||
|
||||
1. **仅传输到电脑** — `uploadPdfToServer()`:纯上传,设置 `Uploaded(fileId, taskId=null)`
|
||||
2. **上传并处理 (OCR PDF)** — `uploadAndProcess("ocrpdf")`:上传 + 创建 OCR 任务
|
||||
3. **上传并处理 (Markdown)** — `uploadAndProcess("markdown")`:上传 + 创建 Markdown 任务
|
||||
|
||||
`UploadState` 状态模型:
|
||||
- `Idle` — 未操作
|
||||
- `Uploading(progress)` — 上传中
|
||||
- `Uploaded(fileId, taskId?)` — 上传成功,taskId 为 null 表示纯传输
|
||||
- `Error(message)` — 上传失败
|
||||
|
||||
---
|
||||
|
||||
### PC 管理面板 ✅
|
||||
|
||||
浏览器访问 `/dashboard`,包含:
|
||||
|
||||
#### 统计卡片
|
||||
- 已上传文件数
|
||||
- 处理任务数(排队中/处理中/已完成)
|
||||
|
||||
#### 文件列表
|
||||
- 显示所有已上传的原始 PDF
|
||||
- 列:文件名、文件 ID、大小、时间、操作
|
||||
- 操作列提供 ⬇ 下载按钮(`/files/{fileId}/download`)
|
||||
|
||||
#### 任务列表
|
||||
- 显示所有处理任务及其状态
|
||||
- 列:文件名、任务 ID、状态(带 badge)、进度条、处理类型、时间、操作
|
||||
- 已完成任务的操作列提供 ⬇ 下载产物按钮
|
||||
|
||||
#### 导航
|
||||
- 顶部导航栏可在图传预览页(/)和管理面板(/dashboard)间切换
|
||||
- 自动刷新(每 2 秒)
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
### MinerU 真实接入 ✅
|
||||
|
||||
替换了之前的模拟 markdown 处理,使用真实 MinerU pipeline 后端:
|
||||
|
||||
- 使用 `aio_do_parse()` 异步接口,不阻塞 FastAPI 事件循环
|
||||
- Pipeline 后端配置:`backend="pipeline"`, `parse_method="auto"`
|
||||
- 环境:Conda 环境 `MinerU`(Python 3.10.20, PyTorch 2.6.0+cu124, CUDA 12.4)
|
||||
- GPU: NVIDIA RTX 4060 Laptop (8 GB VRAM)
|
||||
- 模型缓存路径:`C:/Users/32892/.cache/huggingface/hub/`
|
||||
- `HF_HUB_OFFLINE=1` 强制使用本地缓存,绕过国内网络不可达 huggingface.co 的问题
|
||||
|
||||
**MinerU markdown 输出**:
|
||||
- `{name}.md` — markdown 产物
|
||||
- `images/` — 提取的图片资源
|
||||
- `{name}_result.zip` — `.md + images/` 的完整打包(新增,便于手机端下载后直接使用)
|
||||
|
||||
**MinerU ocrpdf 输出**:
|
||||
- `{name}_layout.pdf` — 带布局框的 PDF(当前模式)
|
||||
- 注:此处不是真正的 OCRmyPDF 双层 PDF,详见 NEXT_STEPS
|
||||
|
||||
### Markdown ZIP 打包 ✅
|
||||
|
||||
PC 服务器 markdown 处理完成后,自动检查 `images/` 目录:
|
||||
- 有图片 → 打包 `{name}.md` + `images/` 为 `{name}_result.zip`
|
||||
- 无图片 → 仅保留 `.md` 产物
|
||||
- 两种产物(`.md` 和 `.zip`)均注册为独立 artifact,客户端可按需下载
|
||||
- `download_artifact` 支持 `application/zip` MIME 类型
|
||||
|
||||
### 手机端任务管理面板 ✅
|
||||
|
||||
在导出页底部新增 `TaskPanelSection` UI 组件:
|
||||
|
||||
- **任务状态显示**:排队中 / 处理中(进度条) / 已完成 / 失败
|
||||
- **后台轮询**:2 秒间隔轮询 PC 任务状态,自动更新 UI,完成后自动停止
|
||||
- **下载到指定目录**:用户点击"选择目录" → SAF 文件夹选择器 → 点击"下载" → 保存到指定目录
|
||||
- **产物优选**:markdown 任务默认下载 ZIP;ocrpdf 任务默认下载 PDF
|
||||
- **下载进度**:实时显示下载进度条
|
||||
- **已下载状态**:显示"已下载 — 打开"按钮,可打开文件
|
||||
|
||||
涉及文件:
|
||||
- `ExportUiState.kt`:新增 `RemoteTask`、`TaskPanelState`、`DownloadState`
|
||||
- `ExportViewModel.kt`:新增 `_taskPanelState`、轮询逻辑、`downloadResult()`
|
||||
- `ExportScreen.kt`:新增 `TaskPanelSection`、`TaskRow` UI 组件
|
||||
- `ExportActions`:新增 `downloadResult`、`resetDownloadState` 回调
|
||||
|
||||
### Bug 修复
|
||||
|
||||
| 问题 | 原因 | 修复 |
|
||||
|------|------|------|
|
||||
| MinerU 无法处理(SSL 错误) | `huggingface_hub` 启动时在线校验 revision | `main.py` 顶部设置 `HF_HUB_OFFLINE=1` |
|
||||
| `main.py` 重复 `@Composable` 编译错误 | 编辑失误 | 移除重复注解 |
|
||||
| `ButtonDefaults.TextButtonContentPadding.copy()` 不存在 | Material3 API 差异 | 改用 `PaddingValues()` 直接构造 |
|
||||
| `DownloadState.Error` 不含 taskId | 无法区分哪个任务的错误 | 添加 `taskId` 参数 |
|
||||
| WebSocket.send(ByteArray) 编译错误 | OkHttp WebSocket.send 需要 ByteString | 使用 `toByteString()` 扩展 |
|
||||
| 网络权限未申请 | 旧 `tools:node="remove"` 删除声明 | 移除冲突行 |
|
||||
| 明文通信被禁止 | `<domain>` 不支持 CIDR | 改用 `<base-config>` |
|
||||
| 帧未显示在浏览器 | 服务器未广播帧到浏览器客户端 | 添加 broadcast 循环 |
|
||||
| 端口输入框"删除不干净" | `toIntOrNull()` 返回 null 后未更新 | 用 `remember` + `LaunchedEffect` |
|
||||
| 下载的 PDF 为空白页 | `_create_minimal_pdf()` 缺少内容流 | 改为复制原始上传文件 |
|
||||
| 上传进度卡在 0% | `upload_pdf` 未启动 `simulate_processing` | 添加 `asyncio.create_task`(后因分离重构移除) |
|
||||
| Preview 函数编译错误 | 缺少 `onUploadAndProcess` 参数 | 添加 `onUploadAndProcess = {}` |
|
||||
|
||||
---
|
||||
|
||||
## 架构总结
|
||||
|
||||
### 完整数据流
|
||||
|
||||
```
|
||||
相机预览 → liveAnalysis()
|
||||
├── → ML 分析(不变) → 文档页面
|
||||
│
|
||||
├── → Streaming(图传开启时)
|
||||
│ FrameCompressor → FrameDropController → OkHttpStreamClient → PC WS /stream → Browser
|
||||
│
|
||||
└── → 拍照 → 处理 → PDF 生成
|
||||
ExportViewModel
|
||||
├── uploadPdfToServer()
|
||||
│ → PdfUploadClient.uploadPdf() → PC POST /upload/pdf
|
||||
│ → 返回 fileId → Uploaded(fileId, taskId=null)
|
||||
│
|
||||
└── uploadAndProcess(processType)
|
||||
→ PdfUploadClient.uploadPdf() → PC POST /upload/pdf → fileId
|
||||
→ TaskClient.processPdf(fileId, processType) → PC POST /tasks/process → taskId
|
||||
→ Uploaded(fileId, taskId)
|
||||
```
|
||||
|
||||
### PC 服务端架构
|
||||
|
||||
```
|
||||
files_db (dict): fileId → {fileId, fileName, sizeBytes, uploadPath, createdAt}
|
||||
tasks_db (dict): taskId → {taskId, fileId, status, progress, processType, ...}
|
||||
artifacts_db (dict): taskId → [{artifactId, fileName, ...}]
|
||||
artifacts_map (dict): artifactId → {artifactId, fileName, filePath, ...}
|
||||
```
|
||||
|
||||
### AppContainer 新增注入
|
||||
```
|
||||
- networkInfoProvider
|
||||
- okHttpClient
|
||||
- streamClient: StreamClient
|
||||
- pdfUploadClient: PdfUploadClient
|
||||
- taskClient: TaskClient
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PC 端端点总览
|
||||
|
||||
| 端点 | 方法 | 功能 |
|
||||
|------|------|------|
|
||||
| `/health` | GET | 健康检查 |
|
||||
| `/` | GET | 图传预览页面 |
|
||||
| `/stream` | WS | 接收 JPEG 帧 |
|
||||
| `/dashboard` | GET | 管理面板页面 |
|
||||
| `/api/dashboard` | GET | 管理面板 JSON 数据 |
|
||||
| `/upload/pdf` | POST | 上传 PDF(纯上传,201) |
|
||||
| `/tasks/process` | POST | 创建处理任务(202) |
|
||||
| `/tasks/{taskId}` | GET | 查询任务状态 |
|
||||
| `/tasks/{taskId}/artifacts` | GET | 查询任务产物列表 |
|
||||
| `/artifacts/{artifactId}/download` | GET | 下载处理产物 |
|
||||
| `/files/{fileId}/download` | GET | 下载已上传的原始文件 |
|
||||
|
||||
---
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 新增文件(Android 网络层)
|
||||
1. `network/ServerEndpoint.kt`
|
||||
2. `network/NetworkInfoProvider.kt`
|
||||
3. `network/stream/StreamState.kt`
|
||||
4. `network/stream/StreamQualityPreset.kt`
|
||||
5. `network/stream/FrameCompressor.kt`
|
||||
6. `network/stream/FrameDropController.kt`
|
||||
7. `network/stream/OkHttpStreamClient.kt`
|
||||
8. `network/upload/PdfUploadClient.kt`
|
||||
9. `network/tasks/TaskModels.kt`
|
||||
10. `network/tasks/TaskClient.kt`
|
||||
11. `res/xml/network_security_config.xml`
|
||||
12. `network/discovery/DiscoveredHost.kt`(占位,待 P0 实现)
|
||||
13. `network/discovery/DiscoveryState.kt`(占位,待 P0 实现)
|
||||
14. `network/discovery/LanServiceDiscovery.kt`(占位,待 P0 实现)
|
||||
|
||||
### 新增文件(PC)
|
||||
15. `pc-server/main.py`
|
||||
|
||||
### 修改文件
|
||||
|
||||
| 文件 | 修改内容 |
|
||||
|------|---------|
|
||||
| `gradle/libs.versions.toml` | 添加 OkHttp 4.12.0 |
|
||||
| `app/build.gradle.kts` | 添加 OkHttp 依赖 |
|
||||
| `AndroidManifest.xml` | 添加网络权限、网络安全配置 |
|
||||
| `FairScanApp.kt` | 添加 okHttpClient、streamClient、pdfUploadClient、taskClient |
|
||||
| `CameraViewModel.kt` | 添加图传字段和方法、帧率控制 |
|
||||
| `CameraScreen.kt` | 添加 StreamToggleButton |
|
||||
| `SettingsRepository.kt` | 添加 StreamFrameRate、ServerHost、ServerPort 等 |
|
||||
| `SettingsViewModel.kt` | 添加 streamFrameRate、serverHost 等字段 |
|
||||
| `SettingsScreen.kt` | 添加帧率选择、网络配置 UI |
|
||||
| `MainActivity.kt` | 添加上传回调、taskPanelState 收集、downloadResult 回调 |
|
||||
| `ExportViewModel.kt` | 添加 uploadPdfToServer()、uploadAndProcess()、downloadResult()、startPolling() |
|
||||
| `ExportUiState.kt` | 添加 UploadState、RemoteTask、TaskPanelState、DownloadState |
|
||||
| `ExportScreen.kt` | 添加上传按钮、TaskPanelSection、TaskRow UI 组件 |
|
||||
| `pc-server/main.py` | 添加 MinerU 真实接入、ZIP 打包、HF_HUB_OFFLINE |
|
||||
|
||||
---
|
||||
|
||||
## 待实现
|
||||
|
||||
| 项目 | 状态 |
|
||||
|------|------|
|
||||
| **OCRmyPDF 真实接入** | **📌 下一步**(当前 ocrpdf 用 MinerU 生成 layout PDF,非真正双层可搜索 PDF) |
|
||||
| NSD 局域网自动发现 | 📌 占位(接口已定义) |
|
||||
| 设置页"扫描主机"/"测试连接"按钮功能 | 📌 待实现 |
|
||||
| 图传延迟/帧率实时显示 | 🔜 可优化 |
|
||||
|
||||
---
|
||||
|
||||
**修改人**:Claude Code
|
||||
**最后更新**:2026-06-04
|
||||
**修改类型**:Feature - Streaming + Upload/Process Pipeline + Dashboard + Real MinerU + Task Panel + ZIP
|
||||
145
requirements/NEXT_STEPS.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# 下一步实现计划
|
||||
|
||||
## 现状总结
|
||||
|
||||
✅ **P1 实时图传**:已完成
|
||||
✅ **P2/P3 上传与任务处理**:已完成
|
||||
✅ **MinerU 真实接入**:已完成(markdown 处理 + ZIP 打包)
|
||||
✅ **任务管理面板**:已完成(手机端轮询 + 下载到指定目录)
|
||||
|
||||
### MinerU markdown 已实现
|
||||
- 使用 `aio_do_parse()` 异步接口,pipeline 后端
|
||||
- `HF_HUB_OFFLINE=1` 使用本地缓存模型
|
||||
- 输出 `.md` + `images/` + `{name}_result.zip` 三种 artifact
|
||||
|
||||
### 任务管理面板已实现
|
||||
- 手机端 `TaskPanelSection`:排队中 / 处理中 / 已完成 / 失败 四种状态
|
||||
- 2 秒轮询 PC 任务状态,自动更新 UI
|
||||
- SAF 目录选择 → 下载到指定目录 → 打开文件
|
||||
|
||||
### 当前 ocrpdf 的局限性
|
||||
|
||||
⚠️ 当前 `processType=ocrpdf` 使用 MinerU 的 `f_draw_layout_bbox=True` 生成 layout PDF(在 PDF 上画布局框),**不是真正的 OCR 双层 PDF**。
|
||||
|
||||
真正的 OCRmyPDF 应该:
|
||||
- 保留原始 PDF 的视觉外观
|
||||
- 在图像层上叠加透明文字层(text layer)
|
||||
- 结果可通过 Ctrl+F 搜索文字
|
||||
- 文件可被屏幕阅读器朗读
|
||||
|
||||
---
|
||||
|
||||
## 下一步:OCRmyPDF 真实接入 🔥
|
||||
|
||||
### 目标
|
||||
用 `ocrmypdf` 库替换当前 MinerU 的 layout PDF 生成,产出真正的可搜索双层 PDF。
|
||||
|
||||
### 为什么需要 OCRmyPDF 而不是继续用 MinerU 做 ocrpdf
|
||||
|
||||
| 特性 | MinerU layout PDF | OCRmyPDF |
|
||||
|------|-------------------|----------|
|
||||
| 可搜索文字 | ❌ 仅图片上的框 | ✅ 透明文字层 |
|
||||
| 保留原始外观 | ❌ 重新渲染 | ✅ 原样保留 |
|
||||
| 文件大小 | 较小 | 完整保留原 PDF |
|
||||
| 用途 | 可视化版面分析 | 归档、检索、无障碍 |
|
||||
|
||||
### 实现方案
|
||||
|
||||
`ocrmypdf` 是一个 Python 命令行工具/库,在 MinerU 的 conda 环境中安装:
|
||||
|
||||
```bash
|
||||
conda activate MinerU
|
||||
pip install ocrmypdf
|
||||
```
|
||||
|
||||
**统一环境说明**:MinerU 和 OCRmyPDF 共用一个 conda 环境 `MinerU`,PC 服务器始终在该环境下运行:
|
||||
|
||||
```bash
|
||||
conda activate MinerU
|
||||
cd pc-server
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 环境信息
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| Conda 环境名 | `MinerU` |
|
||||
| 环境路径 | `D:/ProgramData/miniconda3/envs/MinerU/` |
|
||||
| Python | 3.10.20 |
|
||||
| PyTorch | 2.6.0+cu124 |
|
||||
| CUDA | 12.4 |
|
||||
| GPU | RTX 4060 Laptop (8 GB VRAM) |
|
||||
| MinerU | 3.0.9(已接入 markdown) |
|
||||
| OCRmyPDF | 15.4.4(✅ 已安装,源码 `F:/datasets_rm/ocRmypdf`,v15.4.4 标签) |
|
||||
| Tesseract | ❌ 待安装(OCRmyPDF 必需依赖) |
|
||||
| 用途 | MinerU markdown 处理 + OCRmyPDF 双层 PDF 处理 |
|
||||
|
||||
### 安装 Tesseract
|
||||
|
||||
OCRmyPDF 依赖 Tesseract 做实际 OCR 文字识别。Windows 安装:
|
||||
|
||||
```bash
|
||||
# 方式1:conda(推荐,与 MinerU 同一环境)
|
||||
conda activate MinerU
|
||||
conda install -c conda-forge tesseract
|
||||
|
||||
# 方式2:手动安装
|
||||
# 下载安装包:https://github.com/UB-Mannheim/tesseract/wiki
|
||||
# 安装后确认:
|
||||
tesseract --list-langs # 应包含 chi_sim, eng
|
||||
```
|
||||
|
||||
安装中文语言包:
|
||||
```bash
|
||||
# conda 方式
|
||||
conda install -c conda-forge tesseract-lang
|
||||
|
||||
# 或手动下载 chi_sim.traineddata 放到 tessdata 目录
|
||||
```
|
||||
|
||||
然后在 `pc-server/main.py` 的 `process_with_mineru` 中,`ocrpdf` 分支改为调用 OCRmyPDF:
|
||||
|
||||
```python
|
||||
import ocrmypdf
|
||||
|
||||
# ocrpdf 分支
|
||||
ocrmypdf.ocr(
|
||||
upload_path_src, # 输入 PDF
|
||||
str(output_dir / f"{base_name}_ocr.pdf"), # 输出 PDF
|
||||
language="chi_sim", # 中文简体
|
||||
output_type="pdf",
|
||||
skip_text=True, # 跳过已有文字层
|
||||
deskew=True, # 纠偏
|
||||
clean=True, # 清理
|
||||
)
|
||||
```
|
||||
|
||||
输出:真正的可搜索双层 PDF。
|
||||
|
||||
### 语言映射
|
||||
|
||||
| MinerU lang | OCRmyPDF language |
|
||||
|-------------|-------------------|
|
||||
| `ch` | `chi_sim` |
|
||||
| `en` | `eng` |
|
||||
| `japan` | `jpn` |
|
||||
| `korean` | `kor` |
|
||||
|
||||
### 待确认
|
||||
|
||||
- [x] `ocrmypdf` 已安装到 MinerU conda 环境(v15.4.4)
|
||||
- [ ] Tesseract OCR 引擎已安装
|
||||
- [ ] Tesseract 语言包(`chi_sim`, `eng`)已安装
|
||||
|
||||
---
|
||||
|
||||
## P0:局域网发现与连接校验(待排期)
|
||||
|
||||
### 目标
|
||||
让手机能够自动发现同一局域网中的 FairScan PC 服务。
|
||||
|
||||
### 已有占位文件
|
||||
- `network/discovery/LanServiceDiscovery.kt`(接口定义)
|
||||
- `network/discovery/DiscoveryState.kt`(状态模型)
|
||||
- `network/discovery/DiscoveredHost.kt`(主机模型)
|
||||
1385
requirements/implementation-plan.md
Normal file
392
requirements/mineru-integration.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# MinerU 接入 FairScan PC Server 对接文档
|
||||
|
||||
> 本文档记录 MinerU 在本机的环境信息、API 用法,以及如何将其接入 FairScan PC 服务器,
|
||||
> 替换当前的模拟处理逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 1. 本机环境信息
|
||||
|
||||
> **统一环境**:MinerU 和 OCRmyPDF 共用一个 conda 环境 `MinerU`,
|
||||
> PC 服务器始终通过 `conda activate MinerU` 启动。
|
||||
|
||||
| 项目 | 值 |
|
||||
|------|-----|
|
||||
| MinerU 源码路径 | `F:/datasets_rm/MinerU/` |
|
||||
| **已安装版本** | **3.0.9** |
|
||||
| **最新版本** | **3.2.2**(446 commits 差距) |
|
||||
| Conda 环境 | `D:/ProgramData/miniconda3/envs/MinerU/` |
|
||||
| Python | 3.10.20 |
|
||||
| PyTorch | 2.6.0+cu124 |
|
||||
| CUDA | 12.4 |
|
||||
| GPU | NVIDIA GeForce RTX 4060 Laptop GPU (8 GB VRAM) |
|
||||
| Transformers | 4.57.6 |
|
||||
| onnxruntime | 1.23.2 |
|
||||
| Pipeline 模型 | ✅ 已下载(HF cache: `C:/Users/32892/.cache/huggingface/hub/models--opendatalab--PDF-Extract-Kit-1.0`) |
|
||||
| VLM 模型 | ✅ 已下载(HF cache: `C:/Users/32892/.cache/huggingface/hub/models--opendatalab--MinerU2.5-2509-1.2B`) |
|
||||
| HF Hub 离线模式 | ✅ `HF_HUB_OFFLINE=1`(main.py 启动时设置) |
|
||||
| OCRmyPDF | ✅ v15.4.4 已安装(源码 `F:/datasets_rm/ocRmypdf`,同一 conda 环境) |
|
||||
| Tesseract | ❌ 待安装(OCRmyPDF 必需依赖) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 前置准备:升级 MinerU(强烈建议)
|
||||
|
||||
当前安装的 3.0.9 与最新 3.2.2 差距较大(446 commits),主要改进包括:
|
||||
|
||||
- **`aio_do_parse()` 异步接口** — 可直接 await 调用,不阻塞 FastAPI 事件循环
|
||||
- **并发锁优化** — Layout/MFR/OCR 使用独立推理锁,减少 GPU 争用
|
||||
- **PDF 渲染修复** — 大量 PDFium 资源泄漏和崩溃修复
|
||||
- **图像分析** — 新增 `image_analysis` 参数
|
||||
- **Client-side 输出生成** — 新增 `client_side_output_generation` 选项
|
||||
|
||||
### 2.1 拉取最新代码
|
||||
|
||||
```bash
|
||||
cd F:/datasets_rm/MinerU
|
||||
git checkout main
|
||||
git pull origin main
|
||||
git checkout mineru-3.2.2-released
|
||||
```
|
||||
|
||||
### 2.2 更新安装
|
||||
|
||||
```bash
|
||||
conda activate MinerU
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
### 2.3 验证
|
||||
|
||||
```bash
|
||||
# 检查版本
|
||||
python -c "from mineru.version import __version__; print(__version__)" # 应为 3.2.2
|
||||
|
||||
# 验证模型可用
|
||||
python -c "
|
||||
from mineru.utils.models_download_utils import auto_download_and_get_model_root_path
|
||||
print('Pipeline:', auto_download_and_get_model_root_path('models/README.md', 'pipeline'))
|
||||
print('VLM:', auto_download_and_get_model_root_path('/', 'vlm'))
|
||||
"
|
||||
```
|
||||
|
||||
> **注意**:如果之后需要用 `model_source=local` 指定自定义模型路径,才需要创建 `~/.mineru.json` 配置文件。默认的 HuggingFace 缓存模式不需要。
|
||||
|
||||
---
|
||||
|
||||
## 3. MinerU 编程接口
|
||||
|
||||
### 3.1 核心函数:`do_parse`
|
||||
|
||||
```python
|
||||
from mineru.cli.common import do_parse, read_fn
|
||||
from mineru.utils.enum_class import MakeMode
|
||||
from pathlib import Path
|
||||
|
||||
def do_parse(
|
||||
output_dir: str, # 输出目录路径
|
||||
pdf_file_names: list[str], # PDF 文件名列表(不含扩展名)
|
||||
pdf_bytes_list: list[bytes], # PDF 文件字节列表
|
||||
p_lang_list: list[str], # 语言列表("ch", "en", "japan" 等)
|
||||
backend: str = "pipeline", # "pipeline" | "vlm-auto-engine" | "hybrid-auto-engine"
|
||||
parse_method: str = "auto", # "auto" | "txt" | "ocr"
|
||||
formula_enable: bool = True,
|
||||
table_enable: bool = True,
|
||||
server_url: str | None = None, # 远程服务器 URL(仅 http-client 后端)
|
||||
f_dump_md: bool = True, # 输出 .md 文件
|
||||
f_dump_middle_json: bool = True, # 输出 _middle.json
|
||||
f_dump_model_output: bool = True, # 输出 _model.json
|
||||
f_dump_orig_pdf: bool = True, # 输出原始 PDF 副本
|
||||
f_dump_content_list: bool = True, # 输出 _content_list.json
|
||||
f_draw_layout_bbox: bool = True, # 输出带布局框的 PDF
|
||||
f_draw_span_bbox: bool = True, # 输出带 span 框的 PDF
|
||||
f_make_md_mode: MakeMode = MakeMode.MM_MD, # Markdown 模式
|
||||
start_page_id: int = 0,
|
||||
end_page_id: int | None = None, # None = 所有页
|
||||
**kwargs,
|
||||
)
|
||||
```
|
||||
|
||||
### 3.2 `read_fn` 辅助函数
|
||||
|
||||
```python
|
||||
from mineru.cli.common import read_fn
|
||||
|
||||
# 读取 PDF 文件为 bytes
|
||||
pdf_bytes = read_fn("F:/path/to/doc.pdf")
|
||||
|
||||
# 也支持图片文件(自动转为 PDF bytes)
|
||||
png_bytes = read_fn("scan.png")
|
||||
```
|
||||
|
||||
### 3.3 输出目录结构
|
||||
|
||||
Pipeline 后端(`backend="pipeline"`)输出:
|
||||
|
||||
```
|
||||
{output_dir}/
|
||||
{pdf_name}/
|
||||
auto/ # parse_method="auto"
|
||||
{pdf_name}.md # ★ Markdown 输出(主要产物)
|
||||
{pdf_name}_middle.json # 中间解析结果
|
||||
{pdf_name}_model.json # 模型原始输出
|
||||
{pdf_name}_content_list.json
|
||||
{pdf_name}_origin.pdf # 原始 PDF 副本
|
||||
{pdf_name}_layout.pdf # 布局可视化
|
||||
{pdf_name}_span.pdf # Span 可视化
|
||||
images/ # 提取的图片
|
||||
```
|
||||
|
||||
### 3.4 语言代码
|
||||
|
||||
| 代码 | 语言 |
|
||||
|------|------|
|
||||
| `ch` | 简体中文 |
|
||||
| `ch_server` | 中文服务器版(较快) |
|
||||
| `ch_lite` | 中文轻量版 |
|
||||
| `en` | 英语 |
|
||||
| `japan` | 日语 |
|
||||
| `korean` | 韩语 |
|
||||
| `chinese_cht` | 繁体中文 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 接入方案
|
||||
|
||||
### 方案 A:直接异步 API 调用(强烈推荐,需 v3.2.2)
|
||||
|
||||
升级到 v3.2.2 后,可以直接使用 `aio_do_parse()` — MinerU 原生异步接口,无需 `asyncio.to_thread()`。
|
||||
|
||||
**优点**:
|
||||
- **原生 async**,直接 await,不阻塞 FastAPI 事件循环
|
||||
- 最简单,不需要进程间通信
|
||||
- 可直接获取输出文件路径
|
||||
|
||||
**前提**:
|
||||
- FairScan PC 服务器在 MinerU conda 环境中运行
|
||||
- `F:/datasets_rm/MinerU` 已通过 `pip install -e .` 安装
|
||||
|
||||
**实现思路**:
|
||||
|
||||
```python
|
||||
# ---- pc-server/main.py 新增代码 ----
|
||||
|
||||
from pathlib import Path
|
||||
from mineru.cli.common import aio_do_parse, read_fn
|
||||
|
||||
async def real_mineru_processing(task_id: str):
|
||||
"""使用 MinerU 异步接口真实处理 PDF"""
|
||||
task = tasks_db.get(task_id)
|
||||
if task is None:
|
||||
return
|
||||
|
||||
file_name = task.get("fileName", "document.pdf")
|
||||
base_name = Path(file_name).stem
|
||||
upload_path = Path(task["uploadPath"])
|
||||
process_type = task.get("processType", "ocrpdf")
|
||||
lang = task.get("options", {}).get("lang", "ch")
|
||||
|
||||
task["status"] = "processing"
|
||||
task["progress"] = 10
|
||||
task["message"] = "MinerU processing started..."
|
||||
|
||||
output_dir = TASKS_DIR / task_id
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
pdf_bytes = read_fn(upload_path)
|
||||
|
||||
try:
|
||||
if process_type == "markdown":
|
||||
await aio_do_parse(
|
||||
output_dir=str(output_dir),
|
||||
pdf_file_names=[base_name],
|
||||
pdf_bytes_list=[pdf_bytes],
|
||||
p_lang_list=[lang],
|
||||
backend="pipeline",
|
||||
f_dump_md=True,
|
||||
f_dump_middle_json=False,
|
||||
f_dump_model_output=False,
|
||||
f_dump_orig_pdf=False,
|
||||
f_dump_content_list=False,
|
||||
f_draw_layout_bbox=False,
|
||||
f_draw_span_bbox=False,
|
||||
)
|
||||
md_path = output_dir / base_name / "auto" / f"{base_name}.md"
|
||||
if md_path.exists():
|
||||
art_id = str(uuid.uuid4())
|
||||
artifacts_db[task_id] = [{
|
||||
"artifactId": art_id, "fileName": f"{base_name}.md",
|
||||
"fileSize": md_path.stat().st_size, "fileType": "md",
|
||||
"filePath": str(md_path),
|
||||
}]
|
||||
artifacts_map[art_id] = artifacts_db[task_id][0]
|
||||
task.update(status="completed", progress=100,
|
||||
message="MinerU Markdown completed")
|
||||
return
|
||||
|
||||
elif process_type == "ocrpdf":
|
||||
await aio_do_parse(
|
||||
output_dir=str(output_dir),
|
||||
pdf_file_names=[base_name],
|
||||
pdf_bytes_list=[pdf_bytes],
|
||||
p_lang_list=[lang],
|
||||
backend="pipeline",
|
||||
f_dump_md=False,
|
||||
f_dump_middle_json=False,
|
||||
f_dump_model_output=False,
|
||||
f_dump_orig_pdf=False,
|
||||
f_dump_content_list=False,
|
||||
f_draw_layout_bbox=True,
|
||||
f_draw_span_bbox=False,
|
||||
)
|
||||
layout_pdf = output_dir / base_name / "auto" / f"{base_name}_layout.pdf"
|
||||
if layout_pdf.exists():
|
||||
art_id = str(uuid.uuid4())
|
||||
artifacts_db[task_id] = [{
|
||||
"artifactId": art_id, "fileName": f"{base_name}_ocr.pdf",
|
||||
"fileSize": layout_pdf.stat().st_size, "fileType": "pdf",
|
||||
"filePath": str(layout_pdf),
|
||||
}]
|
||||
artifacts_map[art_id] = artifacts_db[task_id][0]
|
||||
task.update(status="completed", progress=100,
|
||||
message="OCR PDF completed")
|
||||
return
|
||||
|
||||
task["status"] = "failed"
|
||||
task["message"] = "MinerU did not produce output"
|
||||
|
||||
except Exception as e:
|
||||
task["status"] = "failed"
|
||||
task["message"] = f"MinerU error: {str(e)}"
|
||||
logger.error(f"MinerU task {task_id} failed: {e}")
|
||||
```
|
||||
|
||||
### 方案 B:子进程调用(备选)
|
||||
|
||||
通过 `subprocess` 调用 `mineru` CLI:
|
||||
|
||||
```python
|
||||
import subprocess
|
||||
import asyncio
|
||||
|
||||
async def mineru_subprocess(task_id: str):
|
||||
task = tasks_db[task_id]
|
||||
upload_path = task["uploadPath"]
|
||||
output_dir = TASKS_DIR / task_id
|
||||
|
||||
cmd = [
|
||||
r"D:/ProgramData/miniconda3/envs/MinerU/python.exe",
|
||||
"-m", "mineru.cli.client",
|
||||
"-p", str(upload_path),
|
||||
"-o", str(output_dir),
|
||||
"-b", "pipeline",
|
||||
"-l", "ch",
|
||||
]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
# 轮询进度(可选:监控 stdout 中的进度信息)
|
||||
while True:
|
||||
line = await proc.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
# 解析进度...
|
||||
|
||||
returncode = await proc.wait()
|
||||
if returncode == 0:
|
||||
task["status"] = "completed"
|
||||
else:
|
||||
task["status"] = "failed"
|
||||
```
|
||||
|
||||
**优点**:进程隔离,MinerU 崩溃不影响 FairScan 服务。
|
||||
**缺点**:进度监控困难,需要 IPC。
|
||||
|
||||
### 方案 C:MinerU FastAPI 服务
|
||||
|
||||
运行 MinerU 自带的 FastAPI 服务 `mineru-api` 作为微服务,FairScan 通过 HTTP 调用。
|
||||
|
||||
这一方案与 pc-api-spec.md 中对原子服务的建议一致,但实现复杂度更高。
|
||||
|
||||
---
|
||||
|
||||
## 5. 与 pc-api-spec.md 的对应关系
|
||||
|
||||
根据接口规范,两种 `processType` 与 MinerU 的映射:
|
||||
|
||||
| processType | MinerU 后端 | 输出文件 | 文件类型 |
|
||||
|-------------|-----------|---------|---------|
|
||||
| `markdown` | `backend="pipeline"` | `{name}.md` | `text/markdown` |
|
||||
| `ocrpdf` | `backend="pipeline"` + `f_draw_layout_bbox=True` | `{name}_layout.pdf` | `application/pdf` |
|
||||
|
||||
两种类型共用同一个 MinerU `do_parse` 调用,仅输出选项不同。
|
||||
|
||||
---
|
||||
|
||||
## 6. 接入步骤建议
|
||||
|
||||
### Step 1:升级 MinerU 到最新版
|
||||
|
||||
```bash
|
||||
cd F:/datasets_rm/MinerU
|
||||
git checkout main && git pull origin main
|
||||
git checkout mineru-3.2.2-released
|
||||
conda activate MinerU
|
||||
pip install -e .
|
||||
```
|
||||
|
||||
验证:
|
||||
```bash
|
||||
python -c "from mineru.cli.common import aio_do_parse; print('OK')"
|
||||
```
|
||||
|
||||
### Step 2:切换 PC 服务器运行环境
|
||||
|
||||
```bash
|
||||
conda activate MinerU
|
||||
cd E:/race_save/FairScan_cyy/FairScan/pc-server
|
||||
python main.py
|
||||
```
|
||||
|
||||
### Step 3:替换 `simulate_processing` 为真实 MinerU 调用
|
||||
|
||||
在 `main.py` 中将 `simulate_processing` 替换为 `real_mineru_processing`(参考方案 A 的实现)。
|
||||
|
||||
### Step 4:端到端测试
|
||||
|
||||
1. 用小 PDF(1-2 页)先用 `parse_method="txt"` 测试(速度快)
|
||||
2. 确认无误后切换为 `parse_method="auto"`(完整 OCR+公式+表格)
|
||||
3. 测试处理完成后产物下载
|
||||
|
||||
---
|
||||
|
||||
## 7. 注意事项
|
||||
|
||||
| 项目 | 说明 |
|
||||
|------|------|
|
||||
| **GPU 显存** | RTX 4060 有 8GB VRAM。pipeline 后端约需 4-6GB,VLM 后端约需 6-8GB。建议用 pipeline 后端。 |
|
||||
| **处理速度** | 普通 A4 PDF,pipeline 后端约 3-8 秒/页(取决于内容复杂度)。 |
|
||||
| **语言** | 默认传 `ch`(简体中文)。FairScan 可扩展语言选择功能。 |
|
||||
| **页数限制** | 可用 `start_page_id` / `end_page_id` 限制处理范围。 |
|
||||
| **大文件** | PDF > 100 页建议分批处理。 |
|
||||
| **超时** | 单次处理时间与页数成正比,不要设置过短的 HTTP 超时。 |
|
||||
| **锁模型** | `do_parse` 不是线程安全的。FastAPI 的 `async` 端点应在线程池中调用,避免阻塞事件循环。 |
|
||||
| **错误处理** | `do_parse` 出错会抛出异常,需捕获并设置 `task["status"] = "failed"`。 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 关键参考文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `F:/datasets_rm/MinerU/mineru/cli/common.py` | `do_parse()` 主入口 |
|
||||
| `F:/datasets_rm/MinerU/mineru/cli/client.py` | CLI 参数定义 |
|
||||
| `F:/datasets_rm/MinerU/mineru/cli/output_paths.py` | 输出路径解析 |
|
||||
| `F:/datasets_rm/MinerU/mineru/utils/config_reader.py` | 配置读取 |
|
||||
| `F:/datasets_rm/MinerU/mineru/utils/enum_class.py` | 枚举类型定义 |
|
||||
| `F:/datasets_rm/MinerU/mineru.template.json` | 配置文件模板 |
|
||||
| `E:/race_save/FairScan_cyy/FairScan/pc-server/main.py` | FairScan PC 服务器(需修改) |
|
||||
| `E:/race_save/FairScan_cyy/FairScan/requirements/pc-api-spec.md` | API 接口规范 |
|
||||
789
requirements/pc-api-spec.md
Normal file
@@ -0,0 +1,789 @@
|
||||
# FairScan PC 端统一接口规范(草案 v0.1)
|
||||
|
||||
> 本文档定义 FairScan 手机端与 PC 端之间的最小稳定接口契约。
|
||||
>
|
||||
> 适用对象:
|
||||
>
|
||||
> - 人工开发者
|
||||
> - Claude Code
|
||||
> - 其他 AI 编码代理
|
||||
>
|
||||
> 设计目标:
|
||||
>
|
||||
> - 让不同执行者都能按同一接口实现,不因上下文差异而跑偏
|
||||
> - 优先稳定协议与字段,而不是优先绑定具体内部实现
|
||||
> - 允许 PC 端先做“接口占位实现”,后续再逐步接入真实 MinerU / OCRmyPDF
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计范围
|
||||
|
||||
本文档覆盖以下能力:
|
||||
|
||||
1. 局域网服务发现配套信息
|
||||
2. 健康检查接口
|
||||
3. 实时图传接口
|
||||
4. PDF 上传接口
|
||||
5. 统一处理任务接口
|
||||
6. 任务状态查询接口
|
||||
7. 处理产物查询接口
|
||||
8. 处理产物下载接口
|
||||
|
||||
本文档**不**约束以下内容:
|
||||
|
||||
- PC 端内部具体使用什么库执行 MinerU
|
||||
- PC 端内部具体使用什么方式调用 OCRmyPDF
|
||||
- PC 端图传画面最终是显示在网页、桌面窗口还是其他 UI 中
|
||||
- Android 端 UI 的具体布局样式
|
||||
|
||||
也就是说:
|
||||
|
||||
- **本文档约束的是“外部协议”**
|
||||
- **不强制约束“内部实现”**
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心原则
|
||||
|
||||
### 2.1 图传与文档处理解耦
|
||||
|
||||
- 实时图传只负责低延迟画面预览
|
||||
- 正式文档处理只基于手机本地生成的 PDF
|
||||
- 图传流不得直接作为 MinerU / OCRmyPDF 的正式输入
|
||||
|
||||
### 2.2 统一处理接口
|
||||
|
||||
PC 端后处理统一使用一套任务接口。
|
||||
|
||||
支持的处理类型:
|
||||
|
||||
- `markdown`
|
||||
- `ocrpdf`
|
||||
|
||||
差异只体现在:
|
||||
|
||||
- `processType`
|
||||
- 返回产物的 MIME 类型
|
||||
|
||||
### 2.3 手机主动下载结果
|
||||
|
||||
“PC 处理后结果回到手机”在工程上定义为:
|
||||
|
||||
- 手机查询任务状态
|
||||
- 手机获取产物列表
|
||||
- 手机主动下载产物
|
||||
|
||||
不要求 PC 主动回连手机进行推送。
|
||||
|
||||
### 2.4 允许占位实现
|
||||
|
||||
第一阶段允许 PC 端:
|
||||
|
||||
- 返回 mock 任务
|
||||
- 返回 mock 产物
|
||||
- 先不真正接入 MinerU / OCRmyPDF
|
||||
|
||||
只要对外接口契约稳定即可。
|
||||
|
||||
---
|
||||
|
||||
## 3. 术语定义
|
||||
|
||||
### 3.1 File
|
||||
|
||||
指手机上传到 PC 的原始 PDF 文件。
|
||||
|
||||
### 3.2 Task
|
||||
|
||||
指 PC 端异步处理任务。
|
||||
|
||||
### 3.3 Artifact
|
||||
|
||||
指任务完成后可下载的结果文件。
|
||||
|
||||
### 3.4 Primary Artifact
|
||||
|
||||
指该处理类型最核心的主产物:
|
||||
|
||||
- `markdown` -> `.md`
|
||||
- `ocrpdf` -> `.pdf`
|
||||
|
||||
### 3.5 Auxiliary Artifact
|
||||
|
||||
指附加产物,例如:
|
||||
|
||||
- 资源图片
|
||||
- 日志文件
|
||||
- JSON 中间结果
|
||||
- 识别报告
|
||||
|
||||
---
|
||||
|
||||
## 4. 协议总览
|
||||
|
||||
| 能力 | 方法 | 路径 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 健康检查 | GET | `/health` | 检查服务可用性与能力 |
|
||||
| 实时图传 | WS | `/stream` | 接收手机实时图像帧 |
|
||||
| 上传 PDF | POST | `/upload/pdf` | 上传正式文档 PDF |
|
||||
| 创建处理任务 | POST | `/tasks/process` | 发起统一处理任务 |
|
||||
| 查询任务状态 | GET | `/tasks/{taskId}` | 查询任务执行状态 |
|
||||
| 查询任务产物 | GET | `/tasks/{taskId}/artifacts` | 获取结果文件列表 |
|
||||
| 下载产物 | GET | `/artifacts/{artifactId}/download` | 下载结果文件 |
|
||||
| 下载原始文件 | GET | `/files/{fileId}/download` | 下载已上传的原始 PDF |
|
||||
|
||||
默认基础地址示例:
|
||||
|
||||
```text
|
||||
http://{host}:{port}
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
http://192.168.1.10:8080
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 通用约定
|
||||
|
||||
### 5.1 编码与格式
|
||||
|
||||
- JSON 请求与响应统一使用 UTF-8
|
||||
- 除下载接口外,默认返回 `application/json`
|
||||
- 图传 WebSocket 使用二进制消息承载 JPEG 帧
|
||||
|
||||
### 5.2 ID 规则
|
||||
|
||||
以下字段都视为**不透明字符串**:
|
||||
|
||||
- `fileId`
|
||||
- `taskId`
|
||||
- `artifactId`
|
||||
|
||||
客户端不得依赖这些 ID 的内部结构。
|
||||
|
||||
### 5.3 时间字段
|
||||
|
||||
如果服务返回时间字段,建议使用 RFC 3339 / ISO 8601,例如:
|
||||
|
||||
```text
|
||||
2026-06-04T12:34:56Z
|
||||
```
|
||||
|
||||
时间字段不是第一阶段强制要求,但如果提供,应统一格式。
|
||||
|
||||
### 5.4 状态枚举
|
||||
|
||||
任务状态建议使用以下枚举:
|
||||
|
||||
```text
|
||||
queued
|
||||
running
|
||||
completed
|
||||
failed
|
||||
```
|
||||
|
||||
如后续需要,可扩展:
|
||||
|
||||
```text
|
||||
canceled
|
||||
```
|
||||
|
||||
### 5.5 错误返回格式
|
||||
|
||||
推荐所有错误统一返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "INVALID_REQUEST",
|
||||
"message": "processType is required"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
推荐错误码:
|
||||
|
||||
- `INVALID_REQUEST`
|
||||
- `UNSUPPORTED_PROCESS_TYPE`
|
||||
- `FILE_NOT_FOUND`
|
||||
- `TASK_NOT_FOUND`
|
||||
- `ARTIFACT_NOT_FOUND`
|
||||
- `PROCESSING_FAILED`
|
||||
- `SERVICE_UNAVAILABLE`
|
||||
|
||||
### 5.6 版本兼容原则
|
||||
|
||||
- 第一阶段不强制引入 `/api/v1` 路径前缀
|
||||
- 通过 `apiVersion` 字段表达协议版本
|
||||
- 后续如需重大变更,再评估路径版本化
|
||||
|
||||
---
|
||||
|
||||
## 6. 局域网发现配套约定
|
||||
|
||||
### 6.1 mDNS 服务标识
|
||||
|
||||
- service type:`_fairscan._tcp`
|
||||
- service instance name:`FairScan-PC-{deviceName}`
|
||||
|
||||
### 6.2 推荐 TXT Record 字段
|
||||
|
||||
- `name`:设备显示名
|
||||
- `features`:`stream,upload,process,download`
|
||||
- `apiVersion`:如 `1`
|
||||
- `version`:PC 服务版本
|
||||
|
||||
### 6.3 关于 `process` 能力
|
||||
|
||||
这里建议广播能力使用:
|
||||
|
||||
- `process`
|
||||
|
||||
而不是直接广播多个内部工具名。
|
||||
|
||||
原因:
|
||||
|
||||
- 发现层只需表达“能不能处理”
|
||||
- 具体支持哪些 `processType`,可通过 `/health` 返回
|
||||
- 这样后续新增其他处理器时不需要修改发现层语义
|
||||
|
||||
---
|
||||
|
||||
## 7. 健康检查接口
|
||||
|
||||
## 7.1 GET `/health`
|
||||
|
||||
### 作用
|
||||
|
||||
- 判断服务是否在线
|
||||
- 返回最小能力信息
|
||||
- 返回支持的处理类型
|
||||
|
||||
### 请求
|
||||
|
||||
无请求体。
|
||||
|
||||
### 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "FairScan-PC-Office",
|
||||
"status": "ok",
|
||||
"version": "0.1.0",
|
||||
"apiVersion": "1",
|
||||
"features": ["stream", "upload", "process", "download"],
|
||||
"processTypes": ["markdown", "ocrpdf"]
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `name` | string | 是 | 设备显示名 |
|
||||
| `status` | string | 是 | 固定为 `ok` |
|
||||
| `version` | string | 否 | PC 服务版本 |
|
||||
| `apiVersion` | string | 是 | 接口版本 |
|
||||
| `features` | string[] | 是 | 服务能力 |
|
||||
| `processTypes` | string[] | 否 | 当前支持的处理类型 |
|
||||
|
||||
### 状态码
|
||||
|
||||
- `200 OK`
|
||||
|
||||
---
|
||||
|
||||
## 8. 实时图传接口
|
||||
|
||||
## 8.1 WS `/stream`
|
||||
|
||||
### 作用
|
||||
|
||||
接收手机端发送的实时画面帧。
|
||||
|
||||
### 连接方式
|
||||
|
||||
- 客户端发起 WebSocket 连接
|
||||
- 连接成功后开始发送二进制帧
|
||||
- 每条二进制消息代表**一张完整 JPEG 图像**
|
||||
|
||||
### 帧格式
|
||||
|
||||
- 二进制消息
|
||||
- 内容:JPEG 文件完整字节流
|
||||
- 一条消息 = 一帧
|
||||
|
||||
### 服务端要求
|
||||
|
||||
- 服务端可只保留最新帧
|
||||
- 服务端不要求逐帧确认
|
||||
- 服务端允许丢弃旧帧以保证实时性
|
||||
|
||||
### 客户端要求
|
||||
|
||||
- 不得无限积压待发送帧
|
||||
- 若上一帧尚未发完,允许直接丢弃当前帧
|
||||
- 连接断开后由客户端自行决定是否重连
|
||||
|
||||
### 第一阶段最小可接受行为
|
||||
|
||||
- 服务端只需能接收 JPEG 帧并显示或缓存最新一帧
|
||||
- 不要求复杂多端会话管理
|
||||
- 不要求录像、回放、时间轴等高级功能
|
||||
|
||||
### 状态码
|
||||
|
||||
- WebSocket Upgrade 成功即视为可用
|
||||
|
||||
---
|
||||
|
||||
## 9. PDF 上传接口
|
||||
|
||||
## 9.1 POST `/upload/pdf`
|
||||
|
||||
### 作用
|
||||
|
||||
上传手机端正式生成的 PDF 文件。
|
||||
|
||||
### 请求类型
|
||||
|
||||
```text
|
||||
multipart/form-data
|
||||
```
|
||||
|
||||
### 表单字段
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `file` | file | 是 | PDF 文件 |
|
||||
|
||||
### 约束
|
||||
|
||||
- `file` 的 MIME 类型应为 `application/pdf`
|
||||
- 服务端可根据需要限制上传大小
|
||||
- 若文件过大,建议返回 `413 Payload Too Large`
|
||||
|
||||
### 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"fileId": "file-123",
|
||||
"fileName": "Scan 2026-06-04 12.34.56.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"sizeBytes": 1048576
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `fileId` | string | 是 | 服务端文件标识 |
|
||||
| `fileName` | string | 是 | 保存后的文件名 |
|
||||
| `mimeType` | string | 是 | 固定为 `application/pdf` |
|
||||
| `sizeBytes` | number | 是 | 文件字节大小 |
|
||||
|
||||
### 状态码
|
||||
|
||||
- `201 Created`
|
||||
- `400 Bad Request`
|
||||
- `413 Payload Too Large`
|
||||
- `500 Internal Server Error`
|
||||
|
||||
---
|
||||
|
||||
## 10. 统一处理任务接口
|
||||
|
||||
## 10.1 POST `/tasks/process`
|
||||
|
||||
### 作用
|
||||
|
||||
使用统一接口发起后处理任务。
|
||||
|
||||
### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"fileId": "file-123",
|
||||
"processType": "markdown",
|
||||
"options": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 请求字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `fileId` | string | 是 | 由上传接口返回的文件标识 |
|
||||
| `processType` | string | 是 | `markdown` 或 `ocrpdf` |
|
||||
| `options` | object | 否 | 预留扩展字段,首版可为空对象 |
|
||||
|
||||
### 处理类型定义
|
||||
|
||||
| `processType` | 含义 | 预期主产物 |
|
||||
|---|---|---|
|
||||
| `markdown` | 执行 Markdown 转换 | `text/markdown` |
|
||||
| `ocrpdf` | 执行 OCR PDF 处理 | `application/pdf` |
|
||||
|
||||
### 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"taskId": "task-123",
|
||||
"status": "queued",
|
||||
"processType": "markdown",
|
||||
"fileId": "file-123"
|
||||
}
|
||||
```
|
||||
|
||||
### 状态码
|
||||
|
||||
- `202 Accepted`
|
||||
- `400 Bad Request`
|
||||
- `404 Not Found`(`fileId` 不存在)
|
||||
- `422 Unprocessable Entity`(`processType` 不支持时可选)
|
||||
- `500 Internal Server Error`
|
||||
|
||||
### 第一阶段占位实现要求
|
||||
|
||||
如果真实 MinerU / OCRmyPDF 尚未接入,允许这样实现:
|
||||
|
||||
- 接口正常收请求
|
||||
- 正常返回 `taskId`
|
||||
- 任务状态可直接从 `queued` -> `completed`
|
||||
- 产物可先返回 mock 文件或占位文件
|
||||
|
||||
这样做的目标是:
|
||||
|
||||
- 先稳定客户端协议
|
||||
- 先打通 Android 联调链路
|
||||
- 后续再逐步替换成真实处理器
|
||||
|
||||
---
|
||||
|
||||
## 11. 查询任务状态接口
|
||||
|
||||
## 11.1 GET `/tasks/{taskId}`
|
||||
|
||||
### 作用
|
||||
|
||||
返回单个任务的当前状态。
|
||||
|
||||
### 成功响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"taskId": "task-123",
|
||||
"status": "running",
|
||||
"processType": "markdown",
|
||||
"fileId": "file-123",
|
||||
"progress": 50,
|
||||
"message": "processing",
|
||||
"artifactsAvailable": false
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `taskId` | string | 是 | 任务标识 |
|
||||
| `status` | string | 是 | `queued` / `running` / `completed` / `failed` |
|
||||
| `processType` | string | 是 | 任务处理类型 |
|
||||
| `fileId` | string | 是 | 输入文件标识 |
|
||||
| `progress` | number | 否 | 建议 0~100 |
|
||||
| `message` | string | 否 | 当前状态说明 |
|
||||
| `artifactsAvailable` | boolean | 否 | 是否已有可下载产物 |
|
||||
|
||||
### 失败响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "TASK_NOT_FOUND",
|
||||
"message": "task not found"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 状态码
|
||||
|
||||
- `200 OK`
|
||||
- `404 Not Found`
|
||||
|
||||
---
|
||||
|
||||
## 12. 查询任务产物接口
|
||||
|
||||
## 12.1 GET `/tasks/{taskId}/artifacts`
|
||||
|
||||
### 作用
|
||||
|
||||
列出某个任务已经生成的所有产物。
|
||||
|
||||
### 成功响应示例
|
||||
|
||||
#### Markdown 任务示例
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"artifactId": "artifact-1",
|
||||
"fileName": "result.md",
|
||||
"mimeType": "text/markdown",
|
||||
"role": "primary",
|
||||
"sizeBytes": 2048
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### OCR PDF 任务示例
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"artifactId": "artifact-2",
|
||||
"fileName": "result.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"role": "primary",
|
||||
"sizeBytes": 3145728
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `artifactId` | string | 是 | 产物标识 |
|
||||
| `fileName` | string | 是 | 文件名 |
|
||||
| `mimeType` | string | 是 | MIME 类型 |
|
||||
| `role` | string | 是 | `primary` / `auxiliary` / `log` |
|
||||
| `sizeBytes` | number | 否 | 文件大小 |
|
||||
|
||||
### 约束
|
||||
|
||||
- 对 `markdown`,应至少存在一个 `role=primary` 且 `mimeType=text/markdown` 的产物
|
||||
- 对 `ocrpdf`,应至少存在一个 `role=primary` 且 `mimeType=application/pdf` 的产物
|
||||
|
||||
### 状态码
|
||||
|
||||
- `200 OK`
|
||||
- `404 Not Found`
|
||||
|
||||
---
|
||||
|
||||
## 13. 产物下载接口
|
||||
|
||||
## 13.1 GET `/artifacts/{artifactId}/download`
|
||||
|
||||
### 作用
|
||||
|
||||
下载指定产物文件。
|
||||
|
||||
### 响应
|
||||
|
||||
- 响应体为二进制文件流
|
||||
- `Content-Type` 应与产物 `mimeType` 一致
|
||||
- `Content-Disposition` 建议包含文件名
|
||||
|
||||
### 成功行为示例
|
||||
|
||||
- 下载 Markdown:`Content-Type: text/markdown`
|
||||
- 下载 OCR PDF:`Content-Type: application/pdf`
|
||||
|
||||
### 状态码
|
||||
|
||||
- `200 OK`
|
||||
- `404 Not Found`
|
||||
|
||||
---
|
||||
|
||||
## 14. 原始文件下载接口
|
||||
|
||||
## 14.1 GET `/files/{fileId}/download`
|
||||
|
||||
### 作用
|
||||
|
||||
下载已上传但尚未处理的原始 PDF 文件。
|
||||
|
||||
### 响应
|
||||
|
||||
- 响应体为二进制文件流
|
||||
- `Content-Type: application/pdf`
|
||||
- `Content-Disposition` 包含原始文件名
|
||||
|
||||
### 典型用途
|
||||
|
||||
- PC 管理面板中直接下载查看手机上传的原始 PDF
|
||||
- 手机端重新获取已上传的文件
|
||||
|
||||
### 状态码
|
||||
|
||||
- `200 OK`
|
||||
- `404 Not Found`(文件 ID 不存在或文件已从磁盘删除)
|
||||
|
||||
---
|
||||
|
||||
## 15. 两类处理任务的差异说明
|
||||
|
||||
## 15.1 `processType=markdown`
|
||||
|
||||
### 目标
|
||||
|
||||
把 PDF 处理为 Markdown 文档。
|
||||
|
||||
### 最低要求
|
||||
|
||||
- 至少返回一个 `.md` 主产物
|
||||
|
||||
### 可选附加产物
|
||||
|
||||
- 图片资源
|
||||
- 日志
|
||||
- JSON 中间结果
|
||||
|
||||
## 15.2 `processType=ocrpdf`
|
||||
|
||||
### 目标
|
||||
|
||||
把 PDF 处理为 OCR 后的可搜索 PDF。
|
||||
|
||||
### 最低要求
|
||||
|
||||
- 至少返回一个 `.pdf` 主产物
|
||||
|
||||
### 可选附加产物
|
||||
|
||||
- 日志
|
||||
- 识别报告
|
||||
|
||||
---
|
||||
|
||||
## 16. 典型调用流程
|
||||
|
||||
## 16.1 实时图传流程
|
||||
|
||||
1. 手机发现并选择 PC 主机
|
||||
2. 手机调用 `/health` 确认支持 `stream`
|
||||
3. 手机建立 `WS /stream`
|
||||
4. 手机按抽帧策略发送 JPEG 帧
|
||||
5. PC 实时显示最新帧
|
||||
|
||||
## 16.2 文档处理流程
|
||||
|
||||
1. 手机本地生成 PDF
|
||||
2. 手机 `POST /upload/pdf`
|
||||
3. 手机获得 `fileId`
|
||||
4. 手机 `POST /tasks/process`
|
||||
5. 手机获得 `taskId`
|
||||
6. 手机轮询 `GET /tasks/{taskId}`
|
||||
7. 任务完成后,手机调用 `GET /tasks/{taskId}/artifacts`
|
||||
8. 手机调用 `GET /artifacts/{artifactId}/download`
|
||||
9. 手机保存、打开或分享结果
|
||||
|
||||
---
|
||||
|
||||
## 17. 第一阶段可接受的占位实现
|
||||
|
||||
如果当前目标只是让 Android 端和 PC 端先联调通,这一阶段允许:
|
||||
|
||||
### 17.1 `markdown` 占位实现
|
||||
|
||||
- 收到 `processType=markdown`
|
||||
- 直接生成一个示例 `.md` 文件
|
||||
- 任务短时间内进入 `completed`
|
||||
|
||||
### 17.2 `ocrpdf` 占位实现
|
||||
|
||||
- 收到 `processType=ocrpdf`
|
||||
- 直接复制输入 PDF 为新文件,或生成一个占位 PDF
|
||||
- 任务短时间内进入 `completed`
|
||||
|
||||
### 17.3 为什么允许这样做
|
||||
|
||||
这样可以先验证:
|
||||
|
||||
- 接口字段是否稳定
|
||||
- Android 端状态流是否完整
|
||||
- 下载逻辑是否可用
|
||||
- 不同 `mimeType` 的本地处理是否正确
|
||||
|
||||
等这些都稳定后,再接入真实处理器更安全。
|
||||
|
||||
---
|
||||
|
||||
## 18. 对执行者的约束说明
|
||||
|
||||
本节适用于任何执行这份接口文档的人或 AI。
|
||||
|
||||
### 18.1 必须遵守的约束
|
||||
|
||||
- 不要为 `markdown` 和 `ocrpdf` 设计两套独立任务协议
|
||||
- 不要让 Android 端依赖 PC 内部执行器实现细节
|
||||
- 不要把图传流直接作为正式文档处理输入
|
||||
- 不要把“结果回到手机”实现成 PC 主动推送手机的唯一方式
|
||||
|
||||
### 18.2 优先级建议
|
||||
|
||||
如果执行资源有限,优先实现:
|
||||
|
||||
1. `/health`
|
||||
2. `/upload/pdf`
|
||||
3. `/tasks/process`
|
||||
4. `/tasks/{id}`
|
||||
5. `/tasks/{id}/artifacts`
|
||||
6. `/artifacts/{artifactId}/download`
|
||||
7. `WS /stream`
|
||||
|
||||
说明:
|
||||
|
||||
- 如果当前主要目标是联调文档处理链路,可先暂缓图传 UI
|
||||
- 如果当前主要目标是实时性验证,可先实现 `WS /stream`
|
||||
- 但无论如何,统一处理接口契约应保持不变
|
||||
|
||||
---
|
||||
|
||||
## 19. 与 Android 端实现的对应关系
|
||||
|
||||
PC 接口与 Android 模块建议对应如下:
|
||||
|
||||
| PC 接口 | Android 模块 |
|
||||
|---|---|
|
||||
| `/health` | discovery / server endpoint |
|
||||
| `WS /stream` | stream client |
|
||||
| `/upload/pdf` | upload client |
|
||||
| `/tasks/process` | task client |
|
||||
| `/tasks/{id}` | task polling logic |
|
||||
| `/tasks/{id}/artifacts` | artifact query logic |
|
||||
| `/artifacts/{artifactId}/download` | artifact download client |
|
||||
| `/files/{fileId}/download` | raw file download client |
|
||||
|
||||
---
|
||||
|
||||
## 20. 后续扩展预留
|
||||
|
||||
后续如果需要扩展,可在不破坏主契约的情况下增加:
|
||||
|
||||
- 更多 `processType`
|
||||
- 更多 `options` 字段
|
||||
- 任务取消接口
|
||||
- 批量任务接口
|
||||
- 任务日志查询接口
|
||||
- 结果 ZIP 打包下载接口
|
||||
|
||||
但第一阶段不建议过早加入这些扩展。
|
||||
|
||||
---
|
||||
|
||||
## 21. 一句话总结
|
||||
|
||||
这份接口规范的核心思想是:
|
||||
|
||||
- **实时图传走一条轻量、低延迟链路**
|
||||
- **文档处理走一条统一任务接口链路**
|
||||
- **MinerU 与 OCRmyPDF 共用同一处理协议,只通过 `processType` 区分**
|
||||
- **允许先用占位实现把联调跑通,再逐步接入真实处理器**
|
||||
108
requirements/requirements.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# FairScan
|
||||
|
||||
> 此文档为项目需求文档
|
||||
|
||||
## 文件原有的离线扫描功能
|
||||
|
||||
- 相机实时预览、文档边缘检测、自动裁切
|
||||
- 页面编辑(裁切/旋转/滤镜/顺序调整)
|
||||
- PDF/JPEG 导出
|
||||
- 多页扫描管理
|
||||
|
||||
## 手机网络图传功能
|
||||
|
||||
### 变成一个局域网内进行一定压缩广播的实时网络摄像头
|
||||
|
||||
- 手机端通过 WebSocket 将 JPEG 帧发送到 PC
|
||||
- PC 端浏览器实时显示画面
|
||||
- 支持帧率控制(无限制 / 15fps / 10fps / 5fps)
|
||||
- 丢帧策略:上一帧未发送完毕则丢弃当前帧,保证实时性
|
||||
- 连接状态显示(已连接/未连接/出错)
|
||||
|
||||
#### 压缩力度可选
|
||||
|
||||
- **低质量**:最长边 640px,JPEG 质量 45,目标 8~12fps
|
||||
- **均衡**:最长边 960px,JPEG 质量 60,目标 6~10fps(默认)
|
||||
- **高质量**:最长边 1280px,JPEG 质量 75,目标 5~8fps
|
||||
|
||||
## 支持将离线扫描出来的pdf,通过局域网wifi网络协议发送给pc主机
|
||||
|
||||
### 已实现的核心功能
|
||||
|
||||
#### 1. PDF 上传
|
||||
- 手机端在导出页可选择"仅传输到电脑"
|
||||
- 通过 HTTP multipart/form-data 上传到 PC 服务器 `POST /upload/pdf`
|
||||
- 上传进度与状态实时显示
|
||||
- 上传成功后返回 `fileId`,PC 端保存原始 PDF 到 `./uploads/` 目录
|
||||
|
||||
#### 2. 上传+处理
|
||||
- 上传后自动创建处理任务:`POST /tasks/process`
|
||||
- 支持两种处理类型:
|
||||
- **OCR PDF** (`processType=ocrpdf`) — 复制原始 PDF 作为"处理结果"
|
||||
- **Markdown** (`processType=markdown`) — 生成模拟 `.md` 文件
|
||||
- 任务状态轮询:queued → processing (10% → 50% → 90%) → completed
|
||||
- 处理完成后可下载产物
|
||||
|
||||
#### 3. PC 端管理面板
|
||||
- 浏览器访问 `/dashboard` 查看管理界面
|
||||
- 统计卡片:已上传文件数、处理任务数、排队中/处理中/已完成
|
||||
- 文件列表:显示已上传的 PDF,支持下载原始文件
|
||||
- 任务列表:显示所有处理任务,支持下载处理产物
|
||||
- 自动刷新(每 2 秒)
|
||||
- 导航栏:可在图传预览页和管理面板间切换
|
||||
|
||||
### 所连接的wifi可自定义,可以显示出自己的IP和端口
|
||||
|
||||
- 设置页可配置 PC 主机地址和端口
|
||||
- 支持手动输入 IP 和端口
|
||||
- 显示当前手机 IP 地址
|
||||
- 通过 `GET /health` 测试连接
|
||||
- 局域网发现(mDNS/NSD)的占位代码已准备,待完整实现
|
||||
|
||||
### MinerU转成markdowm便于数字化存储 ✅ 已实现
|
||||
|
||||
- `processType=markdown` 处理类型
|
||||
- 使用 MinerU `aio_do_parse()` 异步接口,pipeline 后端
|
||||
- `HF_HUB_OFFLINE=1` 使用本地缓存模型(绕过 huggingface.co 不可达)
|
||||
- 输出产物:`.md` + `images/` + `{name}_result.zip`(ZIP 含 .md + images/)
|
||||
- 手机端可通过任务管理面板查看状态并下载到指定目录
|
||||
|
||||
### 进行OCRmyPDF 转成双层pdf 📌 下一步
|
||||
|
||||
- `processType=ocrpdf` 处理类型
|
||||
- **当前**:使用 MinerU 生成 layout PDF(画布局框,非真正 OCR)
|
||||
- **目标**:接入 `ocrmypdf` 库,生成可搜索双层 PDF
|
||||
- 接口已预留,详见 `requirements/NEXT_STEPS.md`
|
||||
|
||||
## PC 端服务器
|
||||
|
||||
基于 Python FastAPI,提供以下端点:
|
||||
|
||||
| 端点 | 方法 | 功能 |
|
||||
|------|------|------|
|
||||
| `/health` | GET | 健康检查 |
|
||||
| `/` | GET | 图传预览页面 |
|
||||
| `/stream` | WS | 接收 JPEG 帧 |
|
||||
| `/dashboard` | GET | 管理面板页面 |
|
||||
| `/api/dashboard` | GET | 管理面板 JSON 数据 |
|
||||
| `/upload/pdf` | POST | 上传 PDF(纯上传,不处理) |
|
||||
| `/tasks/process` | POST | 创建处理任务 |
|
||||
| `/tasks/{taskId}` | GET | 查询任务状态 |
|
||||
| `/tasks/{taskId}/artifacts` | GET | 查询任务产物列表 |
|
||||
| `/artifacts/{artifactId}/download` | GET | 下载处理产物 |
|
||||
| `/files/{fileId}/download` | GET | 下载已上传的原始文件 |
|
||||
|
||||
### 手机端任务管理面板 ✅ 已实现
|
||||
|
||||
- 导出页底部 `TaskPanelSection`:显示所有上传处理任务
|
||||
- 任务状态:排队中 / 处理中(进度条) / 已完成 / 失败
|
||||
- 2 秒间隔后台轮询,完成后自动停止
|
||||
- 已完成任务:选择下载目录(SAF)→ 下载产物 → 打开文件
|
||||
- Markdown 任务默认下载 ZIP(.md + images/),OCR PDF 任务下载 PDF
|
||||
|
||||
## 后续待实现
|
||||
|
||||
- **P0 OCRmyPDF 真实接入**:用 `ocrmypdf` 库替换 MinerU layout PDF,产出可搜索双层 PDF
|
||||
- **P0 局域网自动发现**:mDNS/NSD 自动发现 PC 服务
|
||||
- **处理结果自动下载**:配置开启后自动下载处理结果
|
||||
- **图传延迟/帧率实时显示**
|
||||