diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index fd9f9ff..3b5706c 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -8,7 +8,6 @@ plugins {
alias(libs.plugins.kotlin.compose)
}
-// 自动版本号管理
val versionPropsFile = file("version.properties")
fun getVersionCode(): Int {
val props = Properties()
@@ -45,7 +44,6 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- // 作者信息
buildConfigField("String", "AUTHOR", "\"无闻风\"")
buildConfigField("String", "BUILD_DATE", "\"${SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(Date())}\"")
}
@@ -90,13 +88,30 @@ dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
+ implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
+ implementation(libs.androidx.navigation.compose)
+ implementation(libs.androidx.datastore.preferences)
+
+ implementation(libs.retrofit)
+ implementation(libs.retrofit.converter.gson)
+ implementation(libs.okhttp)
+ implementation(libs.okhttp.logging.interceptor)
+ implementation(libs.gson)
+
+ implementation(libs.coil.compose)
+
+ implementation(libs.camerax.camera2)
+ implementation(libs.camerax.lifecycle)
+ implementation(libs.camerax.view)
+ implementation(libs.mlkit.barcode.scanning)
+
+ implementation(libs.kotlinx.coroutines.android)
- // 打印SDK
implementation(files("libs/lcprintsdk-release.aar"))
testImplementation(libs.junit)
@@ -106,4 +121,4 @@ dependencies {
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index a88b7ea..589f329 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -2,7 +2,19 @@
+
+
+
+
+
+
+
+
+
+
+
+ android:theme="@style/Theme.Ops_android"
+ android:usesCleartextTraffic="true"
+ tools:targetApi="31">
-
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/example/ops_android/AppContainer.kt b/app/src/main/java/com/example/ops_android/AppContainer.kt
new file mode 100644
index 0000000..2a1416f
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/AppContainer.kt
@@ -0,0 +1,37 @@
+package com.example.ops_android
+
+import android.content.Context
+import com.example.ops_android.data.cache.UserCache
+import com.example.ops_android.data.local.SessionManager
+import com.example.ops_android.data.remote.ApiServiceFactory
+import com.example.ops_android.data.remote.api.*
+import com.example.ops_android.data.repository.*
+import com.example.ops_android.printer.PrinterManager
+import kotlinx.coroutines.runBlocking
+
+class AppContainer(context: Context) {
+ val sessionManager = SessionManager(context)
+ private val apiServiceFactory = ApiServiceFactory(sessionManager)
+
+ val userApi by lazy { apiServiceFactory.create() }
+ val usersApi by lazy { apiServiceFactory.create() }
+ val purchaseApi by lazy { apiServiceFactory.create() }
+ val workOrderApi by lazy { apiServiceFactory.create() }
+ val warehouseApi by lazy { apiServiceFactory.create() }
+ val customerApi by lazy { apiServiceFactory.create() }
+ val scheduleApi by lazy { apiServiceFactory.create() }
+
+ val authRepository by lazy { AuthRepository(userApi, sessionManager) }
+ val orderRepository by lazy { OrderRepository(purchaseApi) }
+ val workOrderRepository by lazy { WorkOrderRepository(workOrderApi) }
+ val warehouseRepository by lazy { WarehouseRepository(warehouseApi) }
+ val customerRepository by lazy { CustomerRepository(customerApi) }
+ val scheduleRepository by lazy { ScheduleRepository(scheduleApi) }
+ val userCache by lazy { UserCache(usersApi) }
+
+ var printerManager: PrinterManager? = null
+
+ init {
+ runBlocking { sessionManager.init() }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/MainActivity.kt b/app/src/main/java/com/example/ops_android/MainActivity.kt
index c0fe4df..3703b28 100644
--- a/app/src/main/java/com/example/ops_android/MainActivity.kt
+++ b/app/src/main/java/com/example/ops_android/MainActivity.kt
@@ -4,32 +4,33 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
-import androidx.compose.material3.*
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Surface
import androidx.compose.runtime.*
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.unit.dp
import com.example.ops_android.printer.PrinterManager
+import com.example.ops_android.ui.LocalAppContainer
+import com.example.ops_android.ui.navigation.OPSNavGraph
import com.example.ops_android.ui.theme.Ops_androidTheme
+import kotlinx.coroutines.runBlocking
class MainActivity : ComponentActivity() {
- private lateinit var printerManager: PrinterManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- printerManager = PrinterManager(this)
+
+ val appContainer = (application as OpsApplication).appContainer
+ val printerManager = PrinterManager(this)
+ appContainer.printerManager = printerManager
+ val isLoggedIn = runBlocking { appContainer.sessionManager.isLoggedIn() }
enableEdgeToEdge()
setContent {
Ops_androidTheme {
- Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
- PrinterTestScreen(
- printerManager = printerManager,
- modifier = Modifier.padding(innerPadding)
- )
+ CompositionLocalProvider(LocalAppContainer provides appContainer) {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ OPSNavGraph(isLoggedIn = isLoggedIn)
+ }
}
}
}
@@ -37,412 +38,11 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
- printerManager.bindService()
+ (application as OpsApplication).appContainer.printerManager?.bindService()
}
override fun onDestroy() {
super.onDestroy()
- printerManager.unbindService()
+ (application as OpsApplication).appContainer.printerManager?.unbindService()
}
}
-
-@Composable
-fun PrinterTestScreen(
- printerManager: PrinterManager,
- modifier: Modifier = Modifier
-) {
- var statusMessage by remember { mutableStateOf("未连接打印服务") }
- var isConnected by remember { mutableStateOf(false) }
-
- DisposableEffect(printerManager) {
- printerManager.setConnectionCallback { connected, message ->
- isConnected = connected
- statusMessage = message
- }
- onDispose { }
- }
-
- Column(
- modifier = modifier
- .fillMaxSize()
- .padding(16.dp)
- .verticalScroll(rememberScrollState()),
- verticalArrangement = Arrangement.spacedBy(12.dp)
- ) {
- Text(
- text = "PDA 打印机测试",
- style = MaterialTheme.typography.headlineMedium,
- modifier = Modifier.padding(bottom = 8.dp)
- )
-
- Text(
- text = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) - ${BuildConfig.AUTHOR}",
- style = MaterialTheme.typography.bodySmall,
- color = MaterialTheme.colorScheme.onSurfaceVariant,
- modifier = Modifier.padding(bottom = 8.dp)
- )
-
- // ── 状态卡片 ──────────────────────────────────────
- Card(
- colors = CardDefaults.cardColors(
- containerColor = if (isConnected)
- MaterialTheme.colorScheme.primaryContainer
- else
- MaterialTheme.colorScheme.errorContainer
- )
- ) {
- Text(
- text = statusMessage,
- modifier = Modifier.padding(16.dp),
- style = MaterialTheme.typography.bodyMedium
- )
- }
-
- HorizontalDivider()
-
- // ══════════════════════════════════════════════════
- // 连接控制
- // ══════════════════════════════════════════════════
- Text("连接控制", style = MaterialTheme.typography.titleMedium)
-
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(
- onClick = { printerManager.bindService() },
- modifier = Modifier.weight(1f)
- ) { Text("绑定服务") }
-
- Button(
- onClick = {
- printerManager.initDevice().fold(
- onSuccess = { statusMessage = "打印机初始化成功" },
- onFailure = { statusMessage = "初始化失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("初始化") }
- }
-
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(
- onClick = {
- printerManager.closeDevice().fold(
- onSuccess = { statusMessage = "打印机已关闭" },
- onFailure = { statusMessage = "关闭失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("关闭打印机") }
-
- Button(
- onClick = {
- printerManager.unbindService()
- isConnected = false
- statusMessage = "服务已解绑"
- },
- modifier = Modifier.weight(1f)
- ) { Text("解绑服务") }
- }
-
- HorizontalDivider()
-
- // ══════════════════════════════════════════════════
- // 快速打印
- // ══════════════════════════════════════════════════
- Text("快速打印", style = MaterialTheme.typography.titleMedium)
-
- Button(
- onClick = {
- printerManager.quickText("Hello Printer!\n测试打印\n").fold(
- onSuccess = { statusMessage = "快速打印成功" },
- onFailure = { statusMessage = "打印失败: ${it.message}" }
- )
- },
- modifier = Modifier.fillMaxWidth()
- ) { Text("快速打印测试") }
-
- Button(
- onClick = {
- printerManager.buildJob()
- .text("工单号: WO-001\n操作员: 张三\n")
- .build()
- .fold(
- onSuccess = { statusMessage = "PrintJob 打印成功" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.fillMaxWidth()
- ) { Text("PrintJob 构建器测试") }
-
- HorizontalDivider()
-
- // ══════════════════════════════════════════════════
- // 单步打印(手动组合)
- // ══════════════════════════════════════════════════
- Text("单步打印", style = MaterialTheme.typography.titleMedium)
-
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(
- onClick = {
- printerManager.ensureOpen().fold(
- onSuccess = {
- printerManager.addText("手动 text\n").fold(
- onSuccess = { statusMessage = "文字已加入缓冲区" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("添加文字") }
-
- Button(
- onClick = {
- printerManager.ensureOpen().fold(
- onSuccess = {
- printerManager.addQR("https://example.com").fold(
- onSuccess = { statusMessage = "QR 已加入缓冲区" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("添加QR") }
- }
-
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(
- onClick = {
- printerManager.ensureOpen().fold(
- onSuccess = {
- printerManager.addBarcode("1234567890").fold(
- onSuccess = { statusMessage = "条码已加入缓冲区" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("添加条码") }
-
- Button(
- onClick = {
- printerManager.ensureOpen().fold(
- onSuccess = {
- printerManager.addLineFeed(3).fold(
- onSuccess = { statusMessage = "空行已加入缓冲区" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("添加空行") }
- }
-
- Button(
- onClick = {
- printerManager.ensureOpen().fold(
- onSuccess = {
- printerManager.commit().fold(
- onSuccess = { statusMessage = "已提交打印" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.fillMaxWidth()
- ) { Text("提交打印 (start)") }
-
- HorizontalDivider()
-
- // ══════════════════════════════════════════════════
- // 样式设置
- // ══════════════════════════════════════════════════
- Text("样式设置", style = MaterialTheme.typography.titleMedium)
-
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(
- onClick = {
- printerManager.ensureOpen().fold(
- onSuccess = { statusMessage = "设备已打开,可设置样式" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- printerManager.setFontSize(32).fold(
- onSuccess = { statusMessage = "大字体已设置" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("大字体") }
-
- Button(
- onClick = {
- printerManager.ensureOpen().fold(
- onSuccess = { statusMessage = "设备已打开" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- printerManager.setBold(true).fold(
- onSuccess = { statusMessage = "粗体已设置" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("粗体") }
- }
-
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(
- onClick = {
- printerManager.setUnderline(true).fold(
- onSuccess = { statusMessage = "下划线已设置" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("下划线") }
-
- Button(
- onClick = {
- printerManager.setDensity(5).fold(
- onSuccess = { statusMessage = "浓度已设置" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("浓度") }
- }
-
- HorizontalDivider()
-
- // ══════════════════════════════════════════════════
- // 标签打印
- // ══════════════════════════════════════════════════
- Text("标签打印(黑标对齐)", style = MaterialTheme.typography.titleMedium)
-
- Button(
- onClick = {
- printerManager.quickLabel("标签打印测试\n工单号: WO-001\n").fold(
- onSuccess = { statusMessage = "标签打印成功" },
- onFailure = { statusMessage = "标签打印失败: ${it.message}" }
- )
- },
- modifier = Modifier.fillMaxWidth()
- ) { Text("快速标签打印") }
-
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
- Button(
- onClick = {
- printerManager.enableMarkDetection(true).fold(
- onSuccess = { statusMessage = "已开启黑标检测" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("开启黑标") }
-
- Button(
- onClick = {
- printerManager.enableMarkDetection(false).fold(
- onSuccess = { statusMessage = "已关闭黑标检测" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.weight(1f)
- ) { Text("关闭黑标") }
- }
-
- Button(
- onClick = {
- printerManager.ensureOpen().fold(
- onSuccess = {
- printerManager.enableMarkDetection(true).fold(
- onSuccess = {
- printerManager.gotoNextMark().fold(
- onSuccess = { statusMessage = "已移动到下一标签" },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- onFailure = { statusMessage = "失败: ${it.message}" }
- )
- },
- modifier = Modifier.fillMaxWidth()
- ) { Text("下一标签") }
-
- HorizontalDivider()
-
- // ══════════════════════════════════════════════════
- // 综合测试
- // ══════════════════════════════════════════════════
- Text("综合测试", style = MaterialTheme.typography.titleMedium)
-
- Button(
- onClick = {
- runComprehensiveTest(printerManager) { msg -> statusMessage = msg }
- },
- modifier = Modifier.fillMaxWidth(),
- colors = ButtonDefaults.buttonColors(
- containerColor = MaterialTheme.colorScheme.secondary
- )
- ) { Text("运行完整测试") }
- }
-}
-
-private fun runComprehensiveTest(
- printerManager: PrinterManager,
- onStatus: (String) -> Unit
-) {
- onStatus("开始综合测试...")
-
- printerManager.initDevice().onFailure {
- onStatus("初始化失败: ${it.message}")
- return
- }
-
- printerManager.setFontSize(32)
- printerManager.setBold(true)
- printerManager.addText("=== 打印测试 ===\n")
-
- printerManager.setFontSize(24)
- printerManager.setBold(false)
- printerManager.addText("普通文本测试\n")
-
- printerManager.setBold(true)
- printerManager.addText("粗体文本测试\n")
- printerManager.setBold(false)
-
- printerManager.setUnderline(true)
- printerManager.addText("下划线文本测试\n")
- printerManager.setUnderline(false)
-
- printerManager.addLineFeed(1)
-
- printerManager.addText("二维码测试:\n")
- printerManager.addQR("https://example.com")
- printerManager.addLineFeed(1)
-
- printerManager.addText("条形码测试:\n")
- printerManager.addBarcode("123456789012")
- printerManager.addLineFeed(3)
-
- printerManager.addText("--------------------------------\n")
- printerManager.addText("测试完成\n")
- printerManager.addLineFeed(2)
-
- printerManager.commit().fold(
- onSuccess = {
- onStatus("综合测试完成,开始打印")
- Thread.sleep(1500)
- printerManager.closeDevice()
- },
- onFailure = { onStatus("提交打印失败: ${it.message}") }
- )
-}
diff --git a/app/src/main/java/com/example/ops_android/OpsApplication.kt b/app/src/main/java/com/example/ops_android/OpsApplication.kt
new file mode 100644
index 0000000..c18b430
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/OpsApplication.kt
@@ -0,0 +1,13 @@
+package com.example.ops_android
+
+import android.app.Application
+
+class OpsApplication : Application() {
+ lateinit var appContainer: AppContainer
+ private set
+
+ override fun onCreate() {
+ super.onCreate()
+ appContainer = AppContainer(this)
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/data/cache/UserCache.kt b/app/src/main/java/com/example/ops_android/data/cache/UserCache.kt
new file mode 100644
index 0000000..69485f6
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/cache/UserCache.kt
@@ -0,0 +1,52 @@
+package com.example.ops_android.data.cache
+
+import com.example.ops_android.data.model.UserDetail
+import com.example.ops_android.data.remote.api.UsersApi
+import kotlinx.coroutines.CompletableDeferred
+import java.util.concurrent.ConcurrentHashMap
+
+class UserCache(
+ private val usersApi: UsersApi
+) {
+ private val cache = ConcurrentHashMap()
+ private val inflightRequests = ConcurrentHashMap>()
+
+ suspend fun fetchUserInfo(userID: Int): UserDetail? {
+ if (userID == 0) return null
+
+ cache[userID]?.let { return it }
+
+ inflightRequests[userID]?.let { return it.await() }
+
+ val deferred = CompletableDeferred()
+ inflightRequests[userID] = deferred
+
+ try {
+ val response = usersApi.getUserInfoFromUserID(userID)
+ val info = if (response.errCode == 0 && response.data?.userinfo != null) {
+ response.data.userinfo.also { cache[userID] = it }
+ } else {
+ null
+ }
+ deferred.complete(info)
+ return info
+ } catch (e: Exception) {
+ deferred.complete(null)
+ return null
+ } finally {
+ inflightRequests.remove(userID)
+ }
+ }
+
+ fun getUsername(userID: Int): String {
+ if (userID == 0) return ""
+ val user = cache[userID]
+ return user?.Username ?: "用户$userID"
+ }
+
+ fun getCachedUser(userID: Int): UserDetail? = cache[userID]
+
+ fun clear() {
+ cache.clear()
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/data/local/SessionManager.kt b/app/src/main/java/com/example/ops_android/data/local/SessionManager.kt
new file mode 100644
index 0000000..1b99031
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/local/SessionManager.kt
@@ -0,0 +1,95 @@
+package com.example.ops_android.data.local
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.example.ops_android.data.model.Cookie
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = "ops_settings")
+
+class SessionManager(private val context: Context) {
+
+ companion object {
+ private val KEY_API_BASE_URL = stringPreferencesKey("api_base_url")
+ private val KEY_COOKIE_VALUE = stringPreferencesKey("cookie_value")
+ private val KEY_COOKIE_NAME = stringPreferencesKey("cookie_name")
+ private val KEY_COOKIE_EXPIRES_AT = stringPreferencesKey("cookie_expires_at")
+ private val KEY_COOKIE_REMEMBER = booleanPreferencesKey("cookie_remember")
+ }
+
+ @Volatile
+ var cachedBaseUrl: String = ""
+ private set
+
+ @Volatile
+ var cachedCookieValue: String = ""
+ private set
+
+ fun clearCookieCache() {
+ cachedCookieValue = ""
+ }
+
+ val apiBaseUrl: Flow = context.dataStore.data.map { prefs -> prefs[KEY_API_BASE_URL] ?: "" }
+ val cookieValue: Flow = context.dataStore.data.map { prefs -> prefs[KEY_COOKIE_VALUE] ?: "" }
+
+ suspend fun init() {
+ cachedBaseUrl = context.dataStore.data.first()[KEY_API_BASE_URL] ?: ""
+ val value = context.dataStore.data.first()[KEY_COOKIE_VALUE]
+ cachedCookieValue = value ?: ""
+ }
+
+ suspend fun getApiBaseUrl(): String = cachedBaseUrl.ifEmpty {
+ context.dataStore.data.first()[KEY_API_BASE_URL]?.also { cachedBaseUrl = it } ?: ""
+ }
+
+ suspend fun setApiBaseUrl(url: String) {
+ cachedBaseUrl = url
+ context.dataStore.edit { it[KEY_API_BASE_URL] = url }
+ }
+
+ suspend fun getCookieValue(): String = cachedCookieValue
+
+ suspend fun saveCookie(cookie: Cookie) {
+ cachedCookieValue = cookie.value ?: ""
+ context.dataStore.edit {
+ it[KEY_COOKIE_VALUE] = cookie.value ?: ""
+ it[KEY_COOKIE_NAME] = cookie.name ?: ""
+ it[KEY_COOKIE_EXPIRES_AT] = cookie.expiresAt ?: ""
+ it[KEY_COOKIE_REMEMBER] = cookie.remember
+ }
+ }
+
+ suspend fun clearCookie() {
+ cachedCookieValue = ""
+ context.dataStore.edit {
+ it.remove(KEY_COOKIE_VALUE)
+ it.remove(KEY_COOKIE_NAME)
+ it.remove(KEY_COOKIE_EXPIRES_AT)
+ it.remove(KEY_COOKIE_REMEMBER)
+ }
+ }
+
+ suspend fun isCookieExpired(): Boolean {
+ val expiresAt = context.dataStore.data.first()[KEY_COOKIE_EXPIRES_AT] ?: return true
+ return try {
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).parse(expiresAt)?.before(Date()) ?: true
+ } catch (e: Exception) { true }
+ }
+
+ suspend fun isLoggedIn(): Boolean {
+ if (cachedCookieValue.isEmpty()) return false
+ return !isCookieExpired()
+ }
+
+ suspend fun logout() = clearCookie()
+}
diff --git a/app/src/main/java/com/example/ops_android/data/model/Container.kt b/app/src/main/java/com/example/ops_android/data/model/Container.kt
new file mode 100644
index 0000000..7f9761d
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/Container.kt
@@ -0,0 +1,11 @@
+package com.example.ops_android.data.model
+
+data class Container(
+ val id: Int = 0,
+ val name: String? = null,
+ val parent_id: Int = 0,
+ val barcode: String? = null,
+ val remark: String? = null,
+ val created_at: String? = null,
+ val user_id: Int? = null
+)
diff --git a/app/src/main/java/com/example/ops_android/data/model/Cookie.kt b/app/src/main/java/com/example/ops_android/data/model/Cookie.kt
new file mode 100644
index 0000000..b9dc035
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/Cookie.kt
@@ -0,0 +1,10 @@
+package com.example.ops_android.data.model
+
+import com.google.gson.annotations.SerializedName
+
+data class Cookie(
+ @SerializedName("Name") val name: String?,
+ @SerializedName("Value") val value: String?,
+ @SerializedName("ExpiresAt") val expiresAt: String?,
+ @SerializedName("Remember") val remember: Boolean = false
+)
diff --git a/app/src/main/java/com/example/ops_android/data/model/Customer.kt b/app/src/main/java/com/example/ops_android/data/model/Customer.kt
new file mode 100644
index 0000000..ff426ee
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/Customer.kt
@@ -0,0 +1,12 @@
+package com.example.ops_android.data.model
+
+data class Customer(
+ val id: Int = 0,
+ val name: String? = null,
+ val phone: String? = null,
+ val email: String? = null,
+ val address: String? = null,
+ val remark: String? = null,
+ val created_at: String? = null,
+ val user_id: Int? = null
+)
diff --git a/app/src/main/java/com/example/ops_android/data/model/Item.kt b/app/src/main/java/com/example/ops_android/data/model/Item.kt
new file mode 100644
index 0000000..7b173e2
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/Item.kt
@@ -0,0 +1,22 @@
+package com.example.ops_android.data.model
+
+data class Item(
+ val id: Int = 0,
+ val name: String? = null,
+ val serial_number: String? = null,
+ val spec: String? = null,
+ val container_id: Int = 0,
+ val barcode: String? = null,
+ val remark: String? = null,
+ val photos: List? = null,
+ val move_history: List? = null,
+ val created_at: String? = null,
+ val user_id: Int? = null
+)
+
+data class MoveHistory(
+ val from_container: String? = null,
+ val to_container: String? = null,
+ val moved_at: String? = null,
+ val user_id: Int? = null
+)
diff --git a/app/src/main/java/com/example/ops_android/data/model/OrderCounts.kt b/app/src/main/java/com/example/ops_android/data/model/OrderCounts.kt
new file mode 100644
index 0000000..460d0cc
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/OrderCounts.kt
@@ -0,0 +1,11 @@
+package com.example.ops_android.data.model
+
+data class OrderCounts(
+ val pending: Int = 0,
+ val ordered: Int = 0,
+ val arrived: Int = 0,
+ val received: Int = 0,
+ val lost: Int = 0,
+ val returned: Int = 0,
+ val total: Int = 0
+)
diff --git a/app/src/main/java/com/example/ops_android/data/model/PurchaseOrder.kt b/app/src/main/java/com/example/ops_android/data/model/PurchaseOrder.kt
new file mode 100644
index 0000000..21c9f96
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/PurchaseOrder.kt
@@ -0,0 +1,31 @@
+package com.example.ops_android.data.model
+
+data class PurchaseOrder(
+ val id: Int = 0,
+ val title: String? = null,
+ val status: String? = null,
+ val remark: String? = null,
+ val link: String? = null,
+ val style_tags: List? = null,
+ val costs: List? = null,
+ val photos: List? = null,
+ val work_order_ids: List? = null,
+ val status_logs: List? = null,
+ val created_at: String? = null,
+ val updated_at: String? = null,
+ val user_id: Int? = null
+)
+
+data class Cost(
+ val amount: Double = 0.0,
+ val currency: String = "CNY",
+ val remark: String? = null
+)
+
+data class StatusLog(
+ val status: String? = null,
+ val comment: String? = null,
+ val photos: List? = null,
+ val created_at: String? = null,
+ val user_id: Int? = null
+)
diff --git a/app/src/main/java/com/example/ops_android/data/model/ScheduleEvent.kt b/app/src/main/java/com/example/ops_android/data/model/ScheduleEvent.kt
new file mode 100644
index 0000000..f95d38d
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/ScheduleEvent.kt
@@ -0,0 +1,12 @@
+package com.example.ops_android.data.model
+
+data class ScheduleEvent(
+ val id: Int = 0,
+ val title: String? = null,
+ val description: String? = null,
+ val start_time: String? = null,
+ val end_time: String? = null,
+ val color: String? = null,
+ val created_at: String? = null,
+ val user_id: Int? = null
+)
diff --git a/app/src/main/java/com/example/ops_android/data/model/Stats.kt b/app/src/main/java/com/example/ops_android/data/model/Stats.kt
new file mode 100644
index 0000000..036bc4f
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/Stats.kt
@@ -0,0 +1,6 @@
+package com.example.ops_android.data.model
+
+data class Stats(
+ val total_containers: Int = 0,
+ val total_items: Int = 0
+)
diff --git a/app/src/main/java/com/example/ops_android/data/model/UserDetail.kt b/app/src/main/java/com/example/ops_android/data/model/UserDetail.kt
new file mode 100644
index 0000000..a3757d6
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/UserDetail.kt
@@ -0,0 +1,8 @@
+package com.example.ops_android.data.model
+
+data class UserDetail(
+ val Username: String? = null,
+ val FirstName: String? = null,
+ val AvatarPath: String? = null,
+ val Role: String? = null
+)
diff --git a/app/src/main/java/com/example/ops_android/data/model/UserInfo.kt b/app/src/main/java/com/example/ops_android/data/model/UserInfo.kt
new file mode 100644
index 0000000..b493bce
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/UserInfo.kt
@@ -0,0 +1,6 @@
+package com.example.ops_android.data.model
+
+data class UserInfo(
+ val ID: Int? = null,
+ val Name: String? = null
+)
diff --git a/app/src/main/java/com/example/ops_android/data/model/WorkOrder.kt b/app/src/main/java/com/example/ops_android/data/model/WorkOrder.kt
new file mode 100644
index 0000000..a6b1152
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/WorkOrder.kt
@@ -0,0 +1,24 @@
+package com.example.ops_android.data.model
+
+data class WorkOrder(
+ val id: Int = 0,
+ val title: String? = null,
+ val description: String? = null,
+ val status: String? = null,
+ val item_ids: List? = null,
+ val purchase_order_ids: List? = null,
+ val customer_id: Int? = null,
+ val commits: List? = null,
+ val created_at: String? = null,
+ val updated_at: String? = null,
+ val user_id: Int? = null
+)
+
+data class Commit(
+ val id: Int = 0,
+ val status: String? = null,
+ val comment: String? = null,
+ val photos: List? = null,
+ val created_at: String? = null,
+ val user_id: Int? = null
+)
diff --git a/app/src/main/java/com/example/ops_android/data/model/WorkOrderCounts.kt b/app/src/main/java/com/example/ops_android/data/model/WorkOrderCounts.kt
new file mode 100644
index 0000000..e51646c
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/model/WorkOrderCounts.kt
@@ -0,0 +1,11 @@
+package com.example.ops_android.data.model
+
+data class WorkOrderCounts(
+ val pending: Int = 0,
+ val checked: Int = 0,
+ val parts_ordered: Int = 0,
+ val repaired: Int = 0,
+ val returned: Int = 0,
+ val unrepairable: Int = 0,
+ val total: Int = 0
+)
diff --git a/app/src/main/java/com/example/ops_android/data/remote/ApiResponse.kt b/app/src/main/java/com/example/ops_android/data/remote/ApiResponse.kt
new file mode 100644
index 0000000..519f5f5
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/ApiResponse.kt
@@ -0,0 +1,24 @@
+package com.example.ops_android.data.remote
+
+import com.google.gson.annotations.SerializedName
+
+data class ApiResponse(
+ @SerializedName("return") val data: T? = null
+) {
+ @SerializedName("err_code")
+ private val _errCode: Any? = null
+
+ val errCode: Int
+ get() = when (_errCode) {
+ is Number -> (_errCode as Number).toInt()
+ is String -> (_errCode as String).toIntOrNull() ?: -1
+ else -> -1
+ }
+
+ val isSuccess: Boolean get() = errCode == 0
+}
+
+data class AuthRequest(
+ @SerializedName("data") val data: Any?,
+ @SerializedName("userCookieValue") val userCookieValue: String?
+)
diff --git a/app/src/main/java/com/example/ops_android/data/remote/ApiServiceFactory.kt b/app/src/main/java/com/example/ops_android/data/remote/ApiServiceFactory.kt
new file mode 100644
index 0000000..e2eb1a3
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/ApiServiceFactory.kt
@@ -0,0 +1,44 @@
+package com.example.ops_android.data.remote
+
+import com.example.ops_android.data.local.SessionManager
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import okhttp3.OkHttpClient
+import okhttp3.logging.HttpLoggingInterceptor
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.util.concurrent.TimeUnit
+
+class ApiServiceFactory(
+ private val sessionManager: SessionManager
+) {
+ val gson: Gson = GsonBuilder()
+ .setLenient()
+ .create()
+
+ fun create(serviceClass: Class): T {
+ return Retrofit.Builder()
+ .baseUrl("http://localhost/")
+ .client(okHttpClient)
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .build()
+ .create(serviceClass)
+ }
+
+ inline fun create(): T = create(T::class.java)
+
+ private val okHttpClient: OkHttpClient by lazy {
+ val loggingInterceptor = HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BASIC
+ }
+
+ OkHttpClient.Builder()
+ .addInterceptor(BaseUrlInterceptor(sessionManager))
+ .addInterceptor(CookieInterceptor(sessionManager))
+ .addInterceptor(loggingInterceptor)
+ .connectTimeout(30, TimeUnit.SECONDS)
+ .readTimeout(30, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .build()
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/AuthInterceptor.kt b/app/src/main/java/com/example/ops_android/data/remote/AuthInterceptor.kt
new file mode 100644
index 0000000..66cf1f2
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/AuthInterceptor.kt
@@ -0,0 +1,40 @@
+package com.example.ops_android.data.remote
+
+import com.example.ops_android.data.local.SessionManager
+import com.google.gson.JsonParser
+import okhttp3.Interceptor
+import okhttp3.Response
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.ResponseBody.Companion.toResponseBody
+
+class AuthInterceptor(
+ private val sessionManager: SessionManager
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val response = chain.proceed(request)
+
+ if (!response.isSuccessful || request.method != "POST") {
+ return response
+ }
+
+ val contentType = response.header("Content-Type") ?: ""
+ if (!contentType.contains("json")) {
+ return response
+ }
+
+ val body = response.body ?: return response
+ val bodyString = body.string()
+
+ try {
+ val errCode = JsonParser.parseString(bodyString).asJsonObject.get("err_code")?.asInt ?: 0
+ if (errCode == -44) {
+ sessionManager.clearCookieCache()
+ }
+ } catch (_: Exception) {}
+
+ val newBody = bodyString.toResponseBody("application/json".toMediaTypeOrNull())
+ return response.newBuilder().body(newBody).build()
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/BaseUrlInterceptor.kt b/app/src/main/java/com/example/ops_android/data/remote/BaseUrlInterceptor.kt
new file mode 100644
index 0000000..a5903a8
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/BaseUrlInterceptor.kt
@@ -0,0 +1,24 @@
+package com.example.ops_android.data.remote
+
+import com.example.ops_android.data.local.SessionManager
+import com.google.gson.Gson
+import okhttp3.Interceptor
+import okhttp3.Response
+import okhttp3.RequestBody.Companion.toRequestBody
+
+class BaseUrlInterceptor(
+ private val sessionManager: SessionManager
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ var request = chain.request()
+ val baseUrl = sessionManager.cachedBaseUrl
+
+ if (baseUrl.isNotEmpty()) {
+ val newUrl = request.url.toString().replace("http://localhost", baseUrl)
+ request = request.newBuilder().url(newUrl).build()
+ }
+
+ return chain.proceed(request)
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/CookieInterceptor.kt b/app/src/main/java/com/example/ops_android/data/remote/CookieInterceptor.kt
new file mode 100644
index 0000000..92da480
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/CookieInterceptor.kt
@@ -0,0 +1,48 @@
+package com.example.ops_android.data.remote
+
+import com.example.ops_android.data.local.SessionManager
+import com.google.gson.Gson
+import okhttp3.Interceptor
+import okhttp3.Response
+import okhttp3.RequestBody.Companion.toRequestBody
+
+class CookieInterceptor(
+ private val sessionManager: SessionManager
+) : Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+
+ if (originalRequest.method != "POST") {
+ return chain.proceed(originalRequest)
+ }
+
+ val cookieValue = sessionManager.cachedCookieValue
+
+ val gson = Gson()
+
+ val oldMap = if (originalRequest.body != null) {
+ val buffer = okio.Buffer()
+ originalRequest.body!!.writeTo(buffer)
+ @Suppress("UNCHECKED_CAST")
+ gson.fromJson(buffer.readUtf8(), Map::class.java) as? Map ?: emptyMap()
+ } else {
+ emptyMap()
+ }
+
+ val newBody = mapOf(
+ "data" to (oldMap as Any),
+ "userCookieValue" to cookieValue
+ )
+
+ val newBodyJson = gson.toJson(newBody)
+ val contentType = originalRequest.body?.contentType()
+ val newRequestBody = newBodyJson.toRequestBody(contentType)
+
+ return chain.proceed(
+ originalRequest.newBuilder()
+ .method(originalRequest.method, newRequestBody)
+ .build()
+ )
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/CustomerApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/CustomerApi.kt
new file mode 100644
index 0000000..c6e8dfc
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/CustomerApi.kt
@@ -0,0 +1,36 @@
+package com.example.ops_android.data.remote.api
+
+import com.example.ops_android.data.model.Customer
+import com.example.ops_android.data.remote.ApiResponse
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+data class CustomerListResponse(
+ val customers: List?
+)
+
+data class CustomerGetRequest(
+ val id: Int
+)
+
+data class CustomerResponse(
+ val customer: Customer?
+)
+
+interface CustomerApi {
+
+ @POST("/customer/list")
+ suspend fun list(@Body request: Map = emptyMap()): ApiResponse
+
+ @POST("/customer/get")
+ suspend fun get(@Body request: CustomerGetRequest): ApiResponse
+
+ @POST("/customer/add")
+ suspend fun add(@Body request: Map): ApiResponse
+
+ @POST("/customer/update")
+ suspend fun update(@Body request: Map): ApiResponse
+
+ @POST("/customer/delete")
+ suspend fun delete(@Body request: Map): ApiResponse
+}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/PurchaseApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/PurchaseApi.kt
new file mode 100644
index 0000000..cfaf2fb
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/PurchaseApi.kt
@@ -0,0 +1,55 @@
+package com.example.ops_android.data.remote.api
+
+import com.example.ops_android.data.model.OrderCounts
+import com.example.ops_android.data.model.PurchaseOrder
+import com.example.ops_android.data.remote.ApiResponse
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+data class GetOrdersRequest(
+ val status: String? = null,
+ val search: String? = null,
+ val offset: Int = 0,
+ val limit: Int = 20
+)
+
+data class OrdersListResponse(
+ val orders: List?,
+ val total: Int = 0
+)
+
+data class GetOrderRequest(
+ val id: Int
+)
+
+data class UpdateOrderStatusRequest(
+ val id: Int,
+ val status: String,
+ val comment: String? = null,
+ val photos: List? = null
+)
+
+data class OrderResponse(
+ val order: PurchaseOrder?
+)
+
+interface PurchaseApi {
+
+ @POST("/purchase/getorders")
+ suspend fun getOrders(@Body request: GetOrdersRequest): ApiResponse
+
+ @POST("/purchase/getorder")
+ suspend fun getOrder(@Body request: GetOrderRequest): ApiResponse
+
+ @POST("/purchase/getordercount")
+ suspend fun getOrderCount(@Body request: Map = emptyMap()): ApiResponse
+
+ @POST("/purchase/updatestatus")
+ suspend fun updateOrderStatus(@Body request: UpdateOrderStatusRequest): ApiResponse
+
+ @POST("/purchase/addorder")
+ suspend fun addOrder(@Body request: Map): ApiResponse
+
+ @POST("/purchase/updateorder")
+ suspend fun updateOrder(@Body request: Map): ApiResponse
+}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/ScheduleApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/ScheduleApi.kt
new file mode 100644
index 0000000..e8539f3
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/ScheduleApi.kt
@@ -0,0 +1,30 @@
+package com.example.ops_android.data.remote.api
+
+import com.example.ops_android.data.model.ScheduleEvent
+import com.example.ops_android.data.remote.ApiResponse
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+data class GetEventsRequest(
+ val start_date: String? = null,
+ val end_date: String? = null
+)
+
+data class EventsResponse(
+ val events: List?
+)
+
+interface ScheduleApi {
+
+ @POST("/schedule/getevents")
+ suspend fun getEvents(@Body request: GetEventsRequest): ApiResponse
+
+ @POST("/schedule/addevent")
+ suspend fun addEvent(@Body request: Map): ApiResponse
+
+ @POST("/schedule/editevent")
+ suspend fun editEvent(@Body request: Map): ApiResponse
+
+ @POST("/schedule/deleevent")
+ suspend fun deleteEvent(@Body request: Map): ApiResponse
+}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/UserApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/UserApi.kt
new file mode 100644
index 0000000..0a530e9
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/UserApi.kt
@@ -0,0 +1,65 @@
+package com.example.ops_android.data.remote.api
+
+import com.example.ops_android.data.model.Cookie
+import com.example.ops_android.data.model.UserDetail
+import com.example.ops_android.data.model.UserInfo
+import com.example.ops_android.data.remote.ApiResponse
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+data class LoginRequest(
+ val username: String,
+ val password: String,
+ val remember: Boolean = true
+)
+
+data class LoginResponse(
+ val cookie: Cookie?
+)
+
+data class RegisterRequest(
+ val username: String,
+ val useremail: String,
+ val userpass: String
+)
+
+data class ChangePasswordRequest(
+ val oldpass: String,
+ val newpass: String
+)
+
+data class ChangeEmailRequest(
+ val newemail: String
+)
+
+data class UpdateInfoRequest(
+ val username: String? = null,
+ val remark: String? = null,
+ val birthday: String? = null
+)
+
+data class UserInfoResponse(
+ val user: UserInfo?,
+ val userInfo: UserDetail?
+)
+
+interface UserApi {
+
+ @POST("/users/login")
+ suspend fun login(@Body request: LoginRequest): ApiResponse
+
+ @POST("/users/register")
+ suspend fun register(@Body request: RegisterRequest): ApiResponse
+
+ @POST("/users/getinfo")
+ suspend fun getUserInfo(@Body request: Map = emptyMap()): ApiResponse
+
+ @POST("/users/changePassword")
+ suspend fun changePassword(@Body request: ChangePasswordRequest): ApiResponse
+
+ @POST("/users/changeEmail")
+ suspend fun changeEmail(@Body request: ChangeEmailRequest): ApiResponse
+
+ @POST("/users/updateInfo")
+ suspend fun updateInfo(@Body request: UpdateInfoRequest): ApiResponse
+}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/UsersApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/UsersApi.kt
new file mode 100644
index 0000000..0ddd589
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/UsersApi.kt
@@ -0,0 +1,16 @@
+package com.example.ops_android.data.remote.api
+
+import com.example.ops_android.data.model.UserDetail
+import com.example.ops_android.data.remote.ApiResponse
+import retrofit2.http.GET
+import retrofit2.http.Path
+
+data class UserInfoByIdResponse(
+ val userinfo: UserDetail?
+)
+
+interface UsersApi {
+
+ @GET("/users/getuserinfo/{userID}")
+ suspend fun getUserInfoFromUserID(@Path("userID") userID: Int): ApiResponse
+}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/WarehouseApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/WarehouseApi.kt
new file mode 100644
index 0000000..bcfa942
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/WarehouseApi.kt
@@ -0,0 +1,84 @@
+package com.example.ops_android.data.remote.api
+
+import com.example.ops_android.data.model.Container
+import com.example.ops_android.data.model.Item
+import com.example.ops_android.data.model.Stats
+import com.example.ops_android.data.remote.ApiResponse
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+data class ListContainerRequest(
+ val parent_id: Int = 0
+)
+
+data class ListContainerResponse(
+ val containers: List?
+)
+
+data class GetContainerRequest(
+ val id: Int
+)
+
+data class ContainerResponse(
+ val container: Container?
+)
+
+data class ListItemRequest(
+ val container_id: Int = 0
+)
+
+data class ListItemResponse(
+ val items: List- ?
+)
+
+data class GetItemRequest(
+ val id: Int
+)
+
+data class ItemResponse(
+ val item: Item?
+)
+
+data class MoveItemRequest(
+ val item_id: Int,
+ val new_container: Int
+)
+
+interface WarehouseApi {
+
+ @POST("/warehouse/list_container")
+ suspend fun listContainer(@Body request: ListContainerRequest): ApiResponse
+
+ @POST("/warehouse/get_container")
+ suspend fun getContainer(@Body request: GetContainerRequest): ApiResponse
+
+ @POST("/warehouse/add_container")
+ suspend fun addContainer(@Body request: Map): ApiResponse
+
+ @POST("/warehouse/update_container")
+ suspend fun updateContainer(@Body request: Map): ApiResponse
+
+ @POST("/warehouse/delete_container")
+ suspend fun deleteContainer(@Body request: Map): ApiResponse
+
+ @POST("/warehouse/list_item")
+ suspend fun listItem(@Body request: ListItemRequest): ApiResponse
+
+ @POST("/warehouse/get_item")
+ suspend fun getItem(@Body request: GetItemRequest): ApiResponse
+
+ @POST("/warehouse/add_item")
+ suspend fun addItem(@Body request: Map): ApiResponse
+
+ @POST("/warehouse/update_item")
+ suspend fun updateItem(@Body request: Map): ApiResponse
+
+ @POST("/warehouse/delete_item")
+ suspend fun deleteItem(@Body request: Map): ApiResponse
+
+ @POST("/warehouse/move_item")
+ suspend fun moveItem(@Body request: MoveItemRequest): ApiResponse
+
+ @POST("/warehouse/get_stats")
+ suspend fun getStats(@Body request: Map = emptyMap()): ApiResponse
+}
diff --git a/app/src/main/java/com/example/ops_android/data/remote/api/WorkOrderApi.kt b/app/src/main/java/com/example/ops_android/data/remote/api/WorkOrderApi.kt
new file mode 100644
index 0000000..90f5047
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/remote/api/WorkOrderApi.kt
@@ -0,0 +1,71 @@
+package com.example.ops_android.data.remote.api
+
+import com.example.ops_android.data.model.PurchaseOrder
+import com.example.ops_android.data.model.WorkOrder
+import com.example.ops_android.data.model.WorkOrderCounts
+import com.example.ops_android.data.remote.ApiResponse
+import retrofit2.http.Body
+import retrofit2.http.POST
+
+data class WorkOrderListRequest(
+ val status: String? = null,
+ val search: String? = null,
+ val offset: Int = 0,
+ val limit: Int = 20
+)
+
+data class WorkOrderListResponse(
+ val work_orders: List?,
+ val total: Int = 0
+)
+
+data class WorkOrderGetRequest(
+ val id: Int
+)
+
+data class WorkOrderResponse(
+ val work_order: WorkOrder?
+)
+
+data class WorkOrderCommitRequest(
+ val work_order_id: Int,
+ val status: String? = null,
+ val comment: String? = null,
+ val photos: List? = null
+)
+
+data class SearchPurchaseOrdersRequest(
+ val search: String,
+ val limit: Int = 10
+)
+
+data class SearchPurchaseOrdersResponse(
+ val purchase_orders: List?
+)
+
+interface WorkOrderApi {
+
+ @POST("/work_order/list")
+ suspend fun list(@Body request: WorkOrderListRequest): ApiResponse
+
+ @POST("/work_order/get")
+ suspend fun get(@Body request: WorkOrderGetRequest): ApiResponse
+
+ @POST("/work_order/count")
+ suspend fun getCount(@Body request: Map = emptyMap()): ApiResponse
+
+ @POST("/work_order/add")
+ suspend fun add(@Body request: Map): ApiResponse
+
+ @POST("/work_order/update")
+ suspend fun update(@Body request: Map): ApiResponse
+
+ @POST("/work_order/delete")
+ suspend fun delete(@Body request: Map): ApiResponse
+
+ @POST("/work_order/commit")
+ suspend fun commit(@Body request: WorkOrderCommitRequest): ApiResponse
+
+ @POST("/work_order/search_purchase_orders")
+ suspend fun searchPurchaseOrders(@Body request: SearchPurchaseOrdersRequest): ApiResponse
+}
diff --git a/app/src/main/java/com/example/ops_android/data/repository/AuthRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/AuthRepository.kt
new file mode 100644
index 0000000..4bd84b3
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/repository/AuthRepository.kt
@@ -0,0 +1,91 @@
+package com.example.ops_android.data.repository
+
+import com.example.ops_android.data.local.SessionManager
+import com.example.ops_android.data.model.Cookie
+import com.example.ops_android.data.model.UserDetail
+import com.example.ops_android.data.remote.api.*
+
+class AuthRepository(
+ private val userApi: UserApi,
+ private val sessionManager: SessionManager
+) {
+ suspend fun login(username: String, password: String, remember: Boolean = true): Result {
+ return try {
+ val response = userApi.login(LoginRequest(username, password, remember))
+ if (response.errCode == 0 && response.data?.cookie != null) {
+ val cookie = response.data.cookie
+ sessionManager.saveCookie(cookie)
+ Result.success(cookie)
+ } else {
+ Result.failure(Exception("登录失败 (err=${response.errCode}, cookie=${response.data?.cookie})"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun register(username: String, email: String, password: String): Result {
+ return try {
+ val response = userApi.register(RegisterRequest(username, email, password))
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("注册失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun getUserInfo(): Result> {
+ return try {
+ val response = userApi.getUserInfo()
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(Pair(response.data.user, response.data.userInfo))
+ } else {
+ Result.failure(Exception("获取用户信息失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun changePassword(oldPass: String, newPass: String): Result {
+ return try {
+ val response = userApi.changePassword(ChangePasswordRequest(oldPass, newPass))
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("修改密码失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun changeEmail(newEmail: String): Result {
+ return try {
+ val response = userApi.changeEmail(ChangeEmailRequest(newEmail))
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("修改邮箱失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun updateInfo(data: UpdateInfoRequest): Result {
+ return try {
+ val response = userApi.updateInfo(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("更新信息失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun logout() {
+ sessionManager.logout()
+ }
+
+ suspend fun isLoggedIn(): Boolean = sessionManager.isLoggedIn()
+
+ suspend fun restoreSession(): Boolean {
+ if (!sessionManager.isLoggedIn()) return false
+ val result = getUserInfo()
+ return result.isSuccess
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/data/repository/CustomerRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/CustomerRepository.kt
new file mode 100644
index 0000000..c6036f5
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/repository/CustomerRepository.kt
@@ -0,0 +1,65 @@
+package com.example.ops_android.data.repository
+
+import com.example.ops_android.data.model.Customer
+import com.example.ops_android.data.remote.api.CustomerApi
+import com.example.ops_android.data.remote.api.CustomerGetRequest
+
+class CustomerRepository(
+ private val customerApi: CustomerApi
+) {
+ suspend fun list(): Result
> {
+ return try {
+ val response = customerApi.list()
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(response.data.customers ?: emptyList())
+ } else {
+ Result.failure(Exception("获取客户列表失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun get(id: Int): Result {
+ return try {
+ val response = customerApi.get(CustomerGetRequest(id))
+ if (response.errCode == 0 && response.data?.customer != null) {
+ Result.success(response.data.customer)
+ } else {
+ Result.failure(Exception("获取客户详情失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun add(data: Map): Result {
+ return try {
+ val response = customerApi.add(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("创建客户失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun update(data: Map): Result {
+ return try {
+ val response = customerApi.update(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("更新客户失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun delete(id: Int): Result {
+ return try {
+ val response = customerApi.delete(mapOf("id" to id))
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("删除客户失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/data/repository/OrderRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/OrderRepository.kt
new file mode 100644
index 0000000..36cb052
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/repository/OrderRepository.kt
@@ -0,0 +1,81 @@
+package com.example.ops_android.data.repository
+
+import com.example.ops_android.data.remote.api.PurchaseApi
+import com.example.ops_android.data.remote.api.GetOrdersRequest
+import com.example.ops_android.data.remote.api.GetOrderRequest
+import com.example.ops_android.data.remote.api.UpdateOrderStatusRequest
+import com.example.ops_android.data.model.OrderCounts
+import com.example.ops_android.data.model.PurchaseOrder
+
+class OrderRepository(
+ private val purchaseApi: PurchaseApi
+) {
+ suspend fun getOrders(status: String? = null, search: String? = null, offset: Int = 0, limit: Int = 20): Result, Int>> {
+ return try {
+ val response = purchaseApi.getOrders(GetOrdersRequest(status, search, offset, limit))
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(Pair(response.data.orders ?: emptyList(), response.data.total))
+ } else {
+ Result.failure(Exception("获取订单列表失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun getOrder(id: Int): Result {
+ return try {
+ val response = purchaseApi.getOrder(GetOrderRequest(id))
+ if (response.errCode == 0 && response.data?.order != null) {
+ Result.success(response.data.order)
+ } else {
+ Result.failure(Exception("获取订单详情失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun getOrderCount(): Result {
+ return try {
+ val response = purchaseApi.getOrderCount()
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(response.data)
+ } else {
+ Result.failure(Exception("获取订单统计失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun updateOrderStatus(id: Int, status: String, comment: String? = null, photos: List? = null): Result {
+ return try {
+ val response = purchaseApi.updateOrderStatus(UpdateOrderStatusRequest(id, status, comment, photos))
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("更新订单状态失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun addOrder(data: Map): Result {
+ return try {
+ val response = purchaseApi.addOrder(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("创建订单失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun updateOrder(data: Map): Result {
+ return try {
+ val response = purchaseApi.updateOrder(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("更新订单失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/data/repository/ScheduleRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/ScheduleRepository.kt
new file mode 100644
index 0000000..8ad9545
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/repository/ScheduleRepository.kt
@@ -0,0 +1,52 @@
+package com.example.ops_android.data.repository
+
+import com.example.ops_android.data.model.ScheduleEvent
+import com.example.ops_android.data.remote.api.ScheduleApi
+import com.example.ops_android.data.remote.api.GetEventsRequest
+
+class ScheduleRepository(
+ private val scheduleApi: ScheduleApi
+) {
+ suspend fun getEvents(startDate: String? = null, endDate: String? = null): Result> {
+ return try {
+ val response = scheduleApi.getEvents(GetEventsRequest(startDate, endDate))
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(response.data.events ?: emptyList())
+ } else {
+ Result.failure(Exception("获取日程失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun addEvent(data: Map): Result {
+ return try {
+ val response = scheduleApi.addEvent(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("创建日程失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun editEvent(data: Map): Result {
+ return try {
+ val response = scheduleApi.editEvent(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("编辑日程失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun deleteEvent(id: Int): Result {
+ return try {
+ val response = scheduleApi.deleteEvent(mapOf("id" to id))
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("删除日程失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/data/repository/WarehouseRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/WarehouseRepository.kt
new file mode 100644
index 0000000..26a12a2
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/repository/WarehouseRepository.kt
@@ -0,0 +1,150 @@
+package com.example.ops_android.data.repository
+
+import com.example.ops_android.data.model.Container
+import com.example.ops_android.data.model.Item
+import com.example.ops_android.data.model.Stats
+import com.example.ops_android.data.remote.api.WarehouseApi
+import com.example.ops_android.data.remote.api.ListContainerRequest
+import com.example.ops_android.data.remote.api.GetContainerRequest
+import com.example.ops_android.data.remote.api.ListItemRequest
+import com.example.ops_android.data.remote.api.GetItemRequest
+import com.example.ops_android.data.remote.api.MoveItemRequest
+
+class WarehouseRepository(
+ private val warehouseApi: WarehouseApi
+) {
+ suspend fun listContainer(parentId: Int = 0): Result> {
+ return try {
+ val response = warehouseApi.listContainer(ListContainerRequest(parentId))
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(response.data.containers ?: emptyList())
+ } else {
+ Result.failure(Exception("获取容器列表失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun getContainer(id: Int): Result {
+ return try {
+ val response = warehouseApi.getContainer(GetContainerRequest(id))
+ if (response.errCode == 0 && response.data?.container != null) {
+ Result.success(response.data.container)
+ } else {
+ Result.failure(Exception("获取容器详情失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun addContainer(data: Map): Result {
+ return try {
+ val response = warehouseApi.addContainer(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("创建容器失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun updateContainer(data: Map): Result {
+ return try {
+ val response = warehouseApi.updateContainer(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("更新容器失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun deleteContainer(id: Int): Result {
+ return try {
+ val response = warehouseApi.deleteContainer(mapOf("id" to id))
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("删除容器失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun listItem(containerId: Int = 0): Result> {
+ return try {
+ val response = warehouseApi.listItem(ListItemRequest(containerId))
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(response.data.items ?: emptyList())
+ } else {
+ Result.failure(Exception("获取物品列表失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun getItem(id: Int): Result- {
+ return try {
+ val response = warehouseApi.getItem(GetItemRequest(id))
+ if (response.errCode == 0 && response.data?.item != null) {
+ Result.success(response.data.item)
+ } else {
+ Result.failure(Exception("获取物品详情失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun addItem(data: Map): Result {
+ return try {
+ val response = warehouseApi.addItem(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("创建物品失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun updateItem(data: Map): Result {
+ return try {
+ val response = warehouseApi.updateItem(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("更新物品失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun deleteItem(id: Int): Result {
+ return try {
+ val response = warehouseApi.deleteItem(mapOf("id" to id))
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("删除物品失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun moveItem(itemId: Int, newContainer: Int): Result {
+ return try {
+ val response = warehouseApi.moveItem(MoveItemRequest(itemId, newContainer))
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("移动物品失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun getStats(): Result {
+ return try {
+ val response = warehouseApi.getStats()
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(response.data)
+ } else {
+ Result.failure(Exception("获取仓库统计失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/data/repository/WorkOrderRepository.kt b/app/src/main/java/com/example/ops_android/data/repository/WorkOrderRepository.kt
new file mode 100644
index 0000000..a8d79c0
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/data/repository/WorkOrderRepository.kt
@@ -0,0 +1,106 @@
+package com.example.ops_android.data.repository
+
+import com.example.ops_android.data.model.PurchaseOrder
+import com.example.ops_android.data.model.WorkOrder
+import com.example.ops_android.data.model.WorkOrderCounts
+import com.example.ops_android.data.remote.api.WorkOrderApi
+import com.example.ops_android.data.remote.api.WorkOrderListRequest
+import com.example.ops_android.data.remote.api.WorkOrderGetRequest
+import com.example.ops_android.data.remote.api.WorkOrderCommitRequest
+import com.example.ops_android.data.remote.api.SearchPurchaseOrdersRequest
+
+class WorkOrderRepository(
+ private val workOrderApi: WorkOrderApi
+) {
+ suspend fun list(status: String? = null, search: String? = null, offset: Int = 0, limit: Int = 20): Result, Int>> {
+ return try {
+ val response = workOrderApi.list(WorkOrderListRequest(status, search, offset, limit))
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(Pair(response.data.work_orders ?: emptyList(), response.data.total))
+ } else {
+ Result.failure(Exception("获取工单列表失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun get(id: Int): Result {
+ return try {
+ val response = workOrderApi.get(WorkOrderGetRequest(id))
+ if (response.errCode == 0 && response.data?.work_order != null) {
+ Result.success(response.data.work_order)
+ } else {
+ Result.failure(Exception("获取工单详情失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun getCount(): Result {
+ return try {
+ val response = workOrderApi.getCount()
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(response.data)
+ } else {
+ Result.failure(Exception("获取工单统计失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun add(data: Map): Result {
+ return try {
+ val response = workOrderApi.add(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("创建工单失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun update(data: Map): Result {
+ return try {
+ val response = workOrderApi.update(data)
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("更新工单失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun delete(id: Int): Result {
+ return try {
+ val response = workOrderApi.delete(mapOf("id" to id))
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("删除工单失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun commit(workOrderId: Int, status: String?, comment: String?, photos: List?): Result {
+ return try {
+ val response = workOrderApi.commit(WorkOrderCommitRequest(workOrderId, status, comment, photos))
+ if (response.errCode == 0) Result.success(Unit)
+ else Result.failure(Exception("提交流程失败"))
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun searchPurchaseOrders(query: String, limit: Int = 10): Result
> {
+ return try {
+ val response = workOrderApi.searchPurchaseOrders(SearchPurchaseOrdersRequest(query, limit))
+ if (response.errCode == 0 && response.data != null) {
+ Result.success(response.data.purchase_orders ?: emptyList())
+ } else {
+ Result.failure(Exception("搜索采购订单失败"))
+ }
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/printer/PrintHelper.kt b/app/src/main/java/com/example/ops_android/printer/PrintHelper.kt
new file mode 100644
index 0000000..c299c13
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/printer/PrintHelper.kt
@@ -0,0 +1,137 @@
+package com.example.ops_android.printer
+
+import com.example.ops_android.data.model.Item
+import com.example.ops_android.data.model.PurchaseOrder
+import com.example.ops_android.data.model.WorkOrder
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+object PrintHelper {
+
+ suspend fun printOrderLabel(
+ printerManager: PrinterManager,
+ order: PurchaseOrder
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
+ printerManager.setFontSize(24)
+ printerManager.setBold(true)
+ printerManager.addText("采购单: ${order.title ?: "-"}\n")
+ printerManager.setFontSize(20)
+ printerManager.setBold(false)
+ if (order.id > 0) {
+ printerManager.addBarcode("PO-${order.id}").fold(
+ onSuccess = {},
+ onFailure = {}
+ )
+ }
+ printerManager.addLineFeed(2)
+ printerManager.commit().fold(
+ onSuccess = {
+ Thread.sleep(1000)
+ printerManager.closeDevice()
+ Result.success(Unit)
+ },
+ onFailure = { Result.failure(it) }
+ )
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun printWorkOrderLabel(
+ printerManager: PrinterManager,
+ workOrder: WorkOrder
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
+ printerManager.setFontSize(24)
+ printerManager.setBold(true)
+ printerManager.addText("工单: ${workOrder.title ?: "-"}\n")
+ printerManager.setFontSize(20)
+ printerManager.setBold(false)
+ if (workOrder.id > 0) {
+ printerManager.addBarcode("WO-${workOrder.id}").fold(
+ onSuccess = {},
+ onFailure = {}
+ )
+ }
+ printerManager.addLineFeed(2)
+ printerManager.commit().fold(
+ onSuccess = {
+ Thread.sleep(1000)
+ printerManager.closeDevice()
+ Result.success(Unit)
+ },
+ onFailure = { Result.failure(it) }
+ )
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun printItemLabel(
+ printerManager: PrinterManager,
+ item: Item
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
+ printerManager.setFontSize(24)
+ printerManager.setBold(true)
+ printerManager.addText("${item.name ?: "-"}\n")
+ printerManager.setFontSize(18)
+ printerManager.setBold(false)
+ item.serial_number?.let { printerManager.addText("编号: $it\n") }
+ item.spec?.let { printerManager.addText("规格: $it\n") }
+ if (item.id > 0) {
+ printerManager.addBarcode("ITEM-${item.id}").fold(
+ onSuccess = {},
+ onFailure = {}
+ )
+ }
+ printerManager.addLineFeed(2)
+ printerManager.commit().fold(
+ onSuccess = {
+ Thread.sleep(1000)
+ printerManager.closeDevice()
+ Result.success(Unit)
+ },
+ onFailure = { Result.failure(it) }
+ )
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+
+ suspend fun printContainerLabel(
+ printerManager: PrinterManager,
+ containerName: String,
+ barcode: String? = null
+ ): Result = withContext(Dispatchers.IO) {
+ try {
+ printerManager.initDevice().onFailure { return@withContext Result.failure(it) }
+ printerManager.setFontSize(24)
+ printerManager.setBold(true)
+ printerManager.addText("容器: $containerName\n")
+ if (barcode != null) {
+ printerManager.setFontSize(18)
+ printerManager.setBold(false)
+ printerManager.addBarcode(barcode).fold(
+ onSuccess = {},
+ onFailure = {}
+ )
+ }
+ printerManager.addLineFeed(2)
+ printerManager.commit().fold(
+ onSuccess = {
+ Thread.sleep(1000)
+ printerManager.closeDevice()
+ Result.success(Unit)
+ },
+ onFailure = { Result.failure(it) }
+ )
+ } catch (e: Exception) {
+ Result.failure(e)
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/AppContainerProvider.kt b/app/src/main/java/com/example/ops_android/ui/AppContainerProvider.kt
new file mode 100644
index 0000000..58ad465
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/AppContainerProvider.kt
@@ -0,0 +1,8 @@
+package com.example.ops_android.ui
+
+import androidx.compose.runtime.compositionLocalOf
+import com.example.ops_android.AppContainer
+
+val LocalAppContainer = compositionLocalOf {
+ error("AppContainer not provided")
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/auth/LoginScreen.kt b/app/src/main/java/com/example/ops_android/ui/auth/LoginScreen.kt
new file mode 100644
index 0000000..27d99f7
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/auth/LoginScreen.kt
@@ -0,0 +1,185 @@
+package com.example.ops_android.ui.auth
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.ui.LocalAppContainer
+
+@Composable
+fun LoginScreen(
+ onLoginSuccess: () -> Unit,
+ onGoToSettings: () -> Unit,
+ viewModel: LoginViewModel = viewModel(factory = LoginViewModel.Factory(LocalAppContainer.current.authRepository))
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ var passwordVisible by remember { mutableStateOf(false) }
+
+ LaunchedEffect(uiState.loginSuccess) {
+ if (uiState.loginSuccess) {
+ onLoginSuccess()
+ viewModel.resetLoginSuccess()
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ Color(0xFF667EEA),
+ Color(0xFF764BA2)
+ )
+ )
+ ),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = "OPS",
+ fontSize = 48.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White
+ )
+
+ Text(
+ text = "运营管理系统",
+ fontSize = 16.sp,
+ color = Color.White.copy(alpha = 0.8f),
+ modifier = Modifier.padding(bottom = 48.dp)
+ )
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 32.dp),
+ shape = RoundedCornerShape(16.dp),
+ colors = CardDefaults.cardColors(containerColor = Color.White)
+ ) {
+ Column(
+ modifier = Modifier.padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ OutlinedTextField(
+ value = uiState.username,
+ onValueChange = viewModel::onUsernameChange,
+ label = { Text("用户名") },
+ leadingIcon = { Icon(Icons.Default.Person, contentDescription = null) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
+ modifier = Modifier.fillMaxWidth(),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color(0xFF667EEA),
+ focusedLabelColor = Color(0xFF667EEA)
+ )
+ )
+
+ OutlinedTextField(
+ value = uiState.password,
+ onValueChange = viewModel::onPasswordChange,
+ label = { Text("密码") },
+ leadingIcon = { Icon(Icons.Default.Lock, contentDescription = null) },
+ trailingIcon = {
+ IconButton(onClick = { passwordVisible = !passwordVisible }) {
+ Icon(
+ if (passwordVisible) Icons.Default.VisibilityOff else Icons.Default.Visibility,
+ contentDescription = null
+ )
+ }
+ },
+ singleLine = true,
+ visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
+ keyboardOptions = KeyboardOptions(
+ keyboardType = KeyboardType.Password,
+ imeAction = ImeAction.Done
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = Color(0xFF667EEA),
+ focusedLabelColor = Color(0xFF667EEA)
+ )
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = uiState.rememberMe,
+ onCheckedChange = viewModel::onRememberMeChange,
+ colors = CheckboxDefaults.colors(
+ checkedColor = Color(0xFF667EEA)
+ )
+ )
+ Text(
+ text = "记住登录",
+ fontSize = 14.sp,
+ color = Color.Gray
+ )
+ }
+
+ uiState.errorMessage?.let { error ->
+ Text(
+ text = error,
+ color = MaterialTheme.colorScheme.error,
+ fontSize = 14.sp,
+ modifier = Modifier.padding(horizontal = 4.dp)
+ )
+ }
+
+ Button(
+ onClick = { viewModel.login() },
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(50.dp),
+ enabled = !uiState.isLoading,
+ shape = RoundedCornerShape(25.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = Color(0xFF667EEA)
+ )
+ ) {
+ if (uiState.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ color = Color.White,
+ strokeWidth = 2.dp
+ )
+ } else {
+ Text("登 录", fontSize = 16.sp)
+ }
+ }
+ }
+ }
+ }
+
+ TextButton(
+ onClick = onGoToSettings,
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .padding(bottom = 32.dp)
+ ) {
+ Text("设置服务器地址", color = Color.White.copy(alpha = 0.7f))
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/auth/LoginViewModel.kt b/app/src/main/java/com/example/ops_android/ui/auth/LoginViewModel.kt
new file mode 100644
index 0000000..256d0b0
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/auth/LoginViewModel.kt
@@ -0,0 +1,84 @@
+package com.example.ops_android.ui.auth
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.example.ops_android.data.repository.AuthRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class LoginUiState(
+ val username: String = "",
+ val password: String = "",
+ val rememberMe: Boolean = true,
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+ val loginSuccess: Boolean = false,
+ val needSettings: Boolean = false
+)
+
+class LoginViewModel(
+ private val authRepository: AuthRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(LoginUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun onUsernameChange(value: String) {
+ _uiState.value = _uiState.value.copy(username = value, errorMessage = null)
+ }
+
+ fun onPasswordChange(value: String) {
+ _uiState.value = _uiState.value.copy(password = value, errorMessage = null)
+ }
+
+ fun onRememberMeChange(value: Boolean) {
+ _uiState.value = _uiState.value.copy(rememberMe = value)
+ }
+
+ fun login() {
+ val state = _uiState.value
+ if (state.username.isBlank() || state.password.isBlank()) {
+ _uiState.value = state.copy(errorMessage = "请输入用户名和密码")
+ return
+ }
+
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
+ val result = authRepository.login(state.username, state.password, state.rememberMe)
+ result.fold(
+ onSuccess = {
+ _uiState.value = _uiState.value.copy(isLoading = false, loginSuccess = true)
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isLoading = false,
+ errorMessage = e.message ?: "登录失败"
+ )
+ }
+ )
+ }
+ }
+
+ fun checkNeedSettings(): Boolean {
+ val result = kotlinx.coroutines.runBlocking {
+ authRepository.isLoggedIn()
+ }
+ return !result
+ }
+
+ fun resetLoginSuccess() {
+ _uiState.value = _uiState.value.copy(loginSuccess = false)
+ }
+
+ class Factory(
+ private val authRepository: AuthRepository
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return LoginViewModel(authRepository) as T
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/customer/CustomerListScreen.kt b/app/src/main/java/com/example/ops_android/ui/customer/CustomerListScreen.kt
new file mode 100644
index 0000000..9fa7143
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/customer/CustomerListScreen.kt
@@ -0,0 +1,116 @@
+package com.example.ops_android.ui.customer
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.data.model.Customer
+import com.example.ops_android.data.repository.CustomerRepository
+import com.example.ops_android.ui.LocalAppContainer
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class CustomerListUiState(
+ val customers: List = emptyList(), val isLoading: Boolean = false, val errorMessage: String? = null,
+ val showForm: Boolean = false, val editingCustomer: Customer? = null,
+ val formName: String = "", val formPhone: String = "", val formEmail: String = "", val formAddress: String = "", val formRemark: String = "",
+ val isSubmitting: Boolean = false
+)
+
+class CustomerListViewModel(private val repo: CustomerRepository) : ViewModel() {
+ private val _s = MutableStateFlow(CustomerListUiState()); val s: StateFlow = _s.asStateFlow()
+ init { load() }
+ private fun load() { viewModelScope.launch { _s.value = _s.value.copy(isLoading = true); repo.list().fold(onSuccess = { _s.value = _s.value.copy(customers = it, isLoading = false) }, onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }) } }
+
+ fun showAddForm() { _s.value = _s.value.copy(showForm = true, editingCustomer = null, formName = "", formPhone = "", formEmail = "", formAddress = "", formRemark = "") }
+ fun showEditForm(c: Customer) { _s.value = _s.value.copy(showForm = true, editingCustomer = c, formName = c.name ?: "", formPhone = c.phone ?: "", formEmail = c.email ?: "", formAddress = c.address ?: "", formRemark = c.remark ?: "") }
+ fun dismissForm() { _s.value = _s.value.copy(showForm = false) }
+ fun onFormName(v: String) { _s.value = _s.value.copy(formName = v) }
+ fun onFormPhone(v: String) { _s.value = _s.value.copy(formPhone = v) }
+ fun onFormEmail(v: String) { _s.value = _s.value.copy(formEmail = v) }
+ fun onFormAddress(v: String) { _s.value = _s.value.copy(formAddress = v) }
+ fun onFormRemark(v: String) { _s.value = _s.value.copy(formRemark = v) }
+
+ fun submitForm() {
+ val st = _s.value
+ if (st.formName.isBlank()) { _s.value = st.copy(errorMessage = "名称不能为空"); return }
+ val data = mapOf("name" to st.formName, "phone" to st.formPhone, "email" to st.formEmail, "address" to st.formAddress, "remark" to st.formRemark)
+ viewModelScope.launch {
+ _s.value = _s.value.copy(isSubmitting = true)
+ val r = if (st.editingCustomer != null) repo.update(data + ("id" to st.editingCustomer.id)) else repo.add(data)
+ r.fold(onSuccess = { _s.value = _s.value.copy(isSubmitting = false, showForm = false); load() }, onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) })
+ }
+ }
+
+ fun deleteCustomer(id: Int) { viewModelScope.launch { repo.delete(id).fold(onSuccess = { load() }, onFailure = { _s.value = _s.value.copy(errorMessage = it.message) }) } }
+
+ class Factory(private val repo: CustomerRepository) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = CustomerListViewModel(repo) as T }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun CustomerListScreen(onBack: () -> Unit) {
+ val vm: CustomerListViewModel = viewModel(factory = CustomerListViewModel.Factory(LocalAppContainer.current.customerRepository))
+ val s by vm.s.collectAsState()
+
+ Scaffold(
+ topBar = { TopAppBar(title = { Text("客户管理") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) },
+ floatingActionButton = { FloatingActionButton(onClick = { vm.showAddForm() }, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "添加", tint = Color.White) } }
+ ) { padding ->
+ if (s.isLoading) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }; return@Scaffold }
+
+ LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ items(s.customers, key = { it.id }) { c ->
+ Card(Modifier.fillMaxWidth().clickable { vm.showEditForm(c) }) {
+ Column(Modifier.padding(12.dp)) {
+ Text(c.name ?: "未命名", fontWeight = FontWeight.Medium)
+ c.phone?.takeIf { it.isNotBlank() }?.let { Text("电话: $it", fontSize = 13.sp, color = Color.Gray) }
+ c.email?.takeIf { it.isNotBlank() }?.let { Text("邮箱: $it", fontSize = 13.sp, color = Color.Gray) }
+ }
+ }
+ }
+ }
+ }
+
+ if (s.showForm) AlertDialog(
+ onDismissRequest = { vm.dismissForm() },
+ title = { Text(if (s.editingCustomer != null) "编辑客户" else "添加客户") },
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(s.formName, vm::onFormName, label = { Text("名称 *") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ OutlinedTextField(s.formPhone, vm::onFormPhone, label = { Text("电话") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ OutlinedTextField(s.formEmail, vm::onFormEmail, label = { Text("邮箱") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ OutlinedTextField(s.formAddress, vm::onFormAddress, label = { Text("地址") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ OutlinedTextField(s.formRemark, vm::onFormRemark, label = { Text("备注") }, minLines = 2, modifier = Modifier.fillMaxWidth())
+ }
+ },
+ confirmButton = {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (s.editingCustomer != null) {
+ TextButton(onClick = { vm.deleteCustomer(s.editingCustomer!!.id) }) { Text("删除", color = Color.Red) }
+ }
+ Button(onClick = { vm.submitForm() }, enabled = !s.isSubmitting) { Text("保存") }
+ }
+ },
+ dismissButton = { TextButton(onClick = { vm.dismissForm() }) { Text("取消") } }
+ )
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/home/HomeScreen.kt b/app/src/main/java/com/example/ops_android/ui/home/HomeScreen.kt
new file mode 100644
index 0000000..cbcbd04
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/home/HomeScreen.kt
@@ -0,0 +1,231 @@
+package com.example.ops_android.ui.home
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.ui.LocalAppContainer
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun HomeScreen(
+ onSearchClick: () -> Unit = {},
+ onScheduleClick: () -> Unit = {},
+ viewModel: HomeViewModel = viewModel(factory = HomeViewModel.Factory(
+ LocalAppContainer.current.orderRepository,
+ LocalAppContainer.current.workOrderRepository,
+ LocalAppContainer.current.scheduleRepository
+ ))
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("OPS 运营管理") },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color(0xFF667EEA),
+ titleContentColor = Color.White
+ )
+ )
+ }
+ ) { padding ->
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ if (uiState.isLoading) {
+ item {
+ Box(
+ modifier = Modifier.fillMaxWidth().padding(32.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ CircularProgressIndicator()
+ }
+ }
+ }
+
+ uiState.errorMessage?.let { error ->
+ item {
+ Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE))) {
+ Text(
+ text = error,
+ modifier = Modifier.padding(16.dp),
+ color = Color(0xFFC62828)
+ )
+ }
+ }
+ }
+
+ // ── 今日日程 ──────────────────────────
+ item {
+ Text("今日日程", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ }
+
+ if (uiState.todayEvents.isEmpty()) {
+ item {
+ Card(colors = CardDefaults.cardColors(containerColor = Color(0xFFF5F5F5))) {
+ Text(
+ "暂无日程安排",
+ modifier = Modifier.padding(16.dp),
+ color = Color.Gray
+ )
+ }
+ }
+ } else {
+ items(uiState.todayEvents) { event ->
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(Icons.Default.Event, null, tint = Color(0xFF667EEA))
+ Spacer(Modifier.width(8.dp))
+ Column {
+ Text(event.title ?: "", fontWeight = FontWeight.Medium)
+ Text(
+ "${event.start_time ?: ""} ~ ${event.end_time ?: ""}",
+ fontSize = 12.sp,
+ color = Color.Gray
+ )
+ }
+ }
+ }
+ }
+ }
+
+ // ── 采购订单统计 ──────────────────────────
+ item {
+ Text("采购订单", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ }
+
+ uiState.orderCounts?.let { counts ->
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ StatCard("待处理", counts.pending, Color(0xFFFF9800), modifier = Modifier.weight(1f))
+ StatCard("已订购", counts.ordered, Color(0xFF2196F3), modifier = Modifier.weight(1f))
+ StatCard("已到货", counts.arrived, Color(0xFF4CAF50), modifier = Modifier.weight(1f))
+ }
+ }
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ StatCard("已收货", counts.received, Color(0xFF4CAF50), modifier = Modifier.weight(1f))
+ StatCard("丢失", counts.lost, Color(0xFFF44336), modifier = Modifier.weight(1f))
+ StatCard("退货", counts.returned, Color(0xFF9C27B0), modifier = Modifier.weight(1f))
+ }
+ }
+ }
+
+ // ── 工单统计 ──────────────────────────
+ item {
+ Text("工单", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ }
+
+ uiState.workOrderCounts?.let { counts ->
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ StatCard("待处理", counts.pending, Color(0xFFFF9800), modifier = Modifier.weight(1f))
+ StatCard("已检查", counts.checked, Color(0xFF2196F3), modifier = Modifier.weight(1f))
+ StatCard("待配件", counts.parts_ordered, Color(0xFF9C27B0), modifier = Modifier.weight(1f))
+ }
+ }
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ StatCard("已修复", counts.repaired, Color(0xFF4CAF50), modifier = Modifier.weight(1f))
+ StatCard("已返还", counts.returned, Color(0xFF4CAF50), modifier = Modifier.weight(1f))
+ StatCard("不可修", counts.unrepairable, Color(0xFFF44336), modifier = Modifier.weight(1f))
+ }
+ }
+ }
+
+ // ── 快捷入口 ──────────────────────────
+ item {
+ Text("快捷入口", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ }
+
+ item {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ QuickLinkCard(Icons.Default.Search, "搜索", Color(0xFF4CAF50), Modifier.weight(1f), onSearchClick)
+ QuickLinkCard(Icons.Default.Event, "日程", Color(0xFF9C27B0), Modifier.weight(1f), onScheduleClick)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun StatCard(label: String, count: Int, color: Color, modifier: Modifier = Modifier) {
+ Card(
+ modifier = modifier,
+ colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f)),
+ shape = RoundedCornerShape(12.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(12.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Text(
+ text = "$count",
+ fontSize = 24.sp,
+ fontWeight = FontWeight.Bold,
+ color = color
+ )
+ Text(
+ text = label,
+ fontSize = 12.sp,
+ color = Color.Gray
+ )
+ }
+ }
+}
+
+@Composable
+private fun QuickLinkCard(icon: ImageVector, label: String, color: Color, modifier: Modifier = Modifier, onClick: () -> Unit = {}) {
+ Card(
+ modifier = modifier.clickable { onClick() },
+ shape = RoundedCornerShape(12.dp),
+ colors = CardDefaults.cardColors(containerColor = color.copy(alpha = 0.1f))
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ Icon(icon, null, tint = color, modifier = Modifier.size(32.dp))
+ Spacer(Modifier.height(8.dp))
+ Text(label, fontSize = 14.sp, color = Color.Gray)
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/home/HomeViewModel.kt b/app/src/main/java/com/example/ops_android/ui/home/HomeViewModel.kt
new file mode 100644
index 0000000..985c15a
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/home/HomeViewModel.kt
@@ -0,0 +1,94 @@
+package com.example.ops_android.ui.home
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.example.ops_android.data.model.OrderCounts
+import com.example.ops_android.data.model.ScheduleEvent
+import com.example.ops_android.data.model.WorkOrderCounts
+import com.example.ops_android.data.repository.OrderRepository
+import com.example.ops_android.data.repository.ScheduleRepository
+import com.example.ops_android.data.repository.WorkOrderRepository
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import java.text.SimpleDateFormat
+import java.util.*
+
+data class HomeUiState(
+ val todayEvents: List = emptyList(),
+ val orderCounts: OrderCounts? = null,
+ val workOrderCounts: WorkOrderCounts? = null,
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null
+)
+
+class HomeViewModel(
+ private val orderRepository: OrderRepository,
+ private val workOrderRepository: WorkOrderRepository,
+ private val scheduleRepository: ScheduleRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(HomeUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var autoRefreshJob: Job? = null
+
+ init {
+ loadData()
+ startAutoRefresh()
+ }
+
+ fun loadData() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null)
+ try {
+ val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
+ val eventsResult = scheduleRepository.getEvents(today, today)
+ val orderCounts = orderRepository.getOrderCount()
+ val workOrderCounts = workOrderRepository.getCount()
+
+ _uiState.value = _uiState.value.copy(
+ todayEvents = eventsResult.getOrNull() ?: emptyList(),
+ orderCounts = orderCounts.getOrNull(),
+ workOrderCounts = workOrderCounts.getOrNull(),
+ isLoading = false
+ )
+ } catch (e: Exception) {
+ _uiState.value = _uiState.value.copy(
+ isLoading = false,
+ errorMessage = e.message
+ )
+ }
+ }
+ }
+
+ private fun startAutoRefresh() {
+ autoRefreshJob = viewModelScope.launch {
+ while (isActive) {
+ delay(5000)
+ loadData()
+ }
+ }
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ autoRefreshJob?.cancel()
+ }
+
+ class Factory(
+ private val orderRepository: OrderRepository,
+ private val workOrderRepository: WorkOrderRepository,
+ private val scheduleRepository: ScheduleRepository
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return HomeViewModel(orderRepository, workOrderRepository, scheduleRepository) as T
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/navigation/OPSNavGraph.kt b/app/src/main/java/com/example/ops_android/ui/navigation/OPSNavGraph.kt
new file mode 100644
index 0000000..814cbec
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/navigation/OPSNavGraph.kt
@@ -0,0 +1,200 @@
+package com.example.ops_android.ui.navigation
+
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Home
+import androidx.compose.material.icons.filled.Inventory
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.ShoppingCart
+import androidx.compose.material.icons.filled.Warehouse
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.navigation.NavDestination.Companion.hierarchy
+import androidx.navigation.NavGraph.Companion.findStartDestination
+import androidx.navigation.NavType
+import androidx.navigation.compose.NavHost
+import androidx.navigation.compose.composable
+import androidx.navigation.compose.currentBackStackEntryAsState
+import androidx.navigation.compose.rememberNavController
+import androidx.navigation.navArgument
+import com.example.ops_android.ui.auth.LoginScreen
+import com.example.ops_android.ui.customer.CustomerListScreen
+import com.example.ops_android.ui.home.HomeScreen
+import com.example.ops_android.ui.order.OrderDetailScreen
+import com.example.ops_android.ui.order.OrderFormScreen
+import com.example.ops_android.ui.order.OrderListScreen
+import com.example.ops_android.ui.profile.ProfileScreen
+import com.example.ops_android.ui.schedule.ScheduleScreen
+import com.example.ops_android.ui.search.SearchScreen
+import com.example.ops_android.ui.settings.SettingsScreen
+import com.example.ops_android.ui.warehouse.ItemDetailScreen
+import com.example.ops_android.ui.warehouse.ItemFormScreen
+import com.example.ops_android.ui.warehouse.WarehouseScreen
+import com.example.ops_android.ui.workorder.WorkOrderDetailScreen
+import com.example.ops_android.ui.workorder.WorkOrderFormScreen
+import com.example.ops_android.ui.workorder.WorkOrderListScreen
+
+object Routes {
+ const val LOGIN = "login"
+ const val MAIN = "main"
+ const val SETTINGS = "settings"
+ const val SEARCH = "search"
+ const val SCHEDULE = "schedule"
+ const val ORDER_DETAIL = "order_detail/{orderId}"
+ const val ORDER_FORM = "order_form?editId={editId}"
+ const val WORK_ORDER_DETAIL = "work_order_detail/{id}"
+ const val WORK_ORDER_FORM = "work_order_form?editId={editId}"
+ const val ITEM_DETAIL = "item_detail/{id}"
+ const val ITEM_FORM = "item_form/{containerId}?editId={editId}"
+ const val CUSTOMER_LIST = "customer_list"
+
+ fun orderDetail(orderId: Int) = "order_detail/$orderId"
+ fun orderForm(editId: Int? = null) = if (editId != null) "order_form?editId=$editId" else "order_form"
+ fun workOrderDetail(id: Int) = "work_order_detail/$id"
+ fun workOrderForm(editId: Int? = null) = if (editId != null) "work_order_form?editId=$editId" else "work_order_form"
+ fun itemDetail(id: Int) = "item_detail/$id"
+ fun itemForm(containerId: Int, editId: Int? = null) = if (editId != null) "item_form/$containerId?editId=$editId" else "item_form/$containerId"
+}
+
+object BottomTabs {
+ const val HOME = "home"
+ const val ORDERS = "orders"
+ const val WORK_ORDERS = "work_orders"
+ const val WAREHOUSE = "warehouse"
+ const val PROFILE = "profile"
+}
+
+data class BottomNavItem(val route: String, val icon: ImageVector, val label: String)
+
+val bottomNavItems = listOf(
+ BottomNavItem(BottomTabs.HOME, Icons.Default.Home, "首页"),
+ BottomNavItem(BottomTabs.ORDERS, Icons.Default.ShoppingCart, "订单"),
+ BottomNavItem(BottomTabs.WORK_ORDERS, Icons.Default.Inventory, "工单"),
+ BottomNavItem(BottomTabs.WAREHOUSE, Icons.Default.Warehouse, "仓库"),
+ BottomNavItem(BottomTabs.PROFILE, Icons.Default.Person, "我的")
+)
+
+@Composable
+fun OPSNavGraph(isLoggedIn: Boolean) {
+ val rootNavController = rememberNavController()
+
+ NavHost(navController = rootNavController, startDestination = if (isLoggedIn) Routes.MAIN else Routes.LOGIN) {
+ composable(Routes.LOGIN) {
+ LoginScreen(
+ onLoginSuccess = { rootNavController.navigate(Routes.MAIN) { popUpTo(Routes.LOGIN) { inclusive = true } } },
+ onGoToSettings = { rootNavController.navigate(Routes.SETTINGS) }
+ )
+ }
+ composable(Routes.MAIN) {
+ MainScreen(
+ onLogout = { rootNavController.navigate(Routes.LOGIN) { popUpTo(Routes.MAIN) { inclusive = true } } },
+ onNavigate = { route -> rootNavController.navigate(route) }
+ )
+ }
+ composable(Routes.SETTINGS) {
+ SettingsScreen(onBack = { rootNavController.popBackStack() })
+ }
+ composable(Routes.SEARCH) {
+ SearchScreen(
+ onOrderClick = { id -> rootNavController.navigate(Routes.orderDetail(id)) },
+ onWorkOrderClick = { id -> rootNavController.navigate(Routes.workOrderDetail(id)) },
+ onItemClick = { id -> rootNavController.navigate(Routes.itemDetail(id)) },
+ onContainerClick = { id -> rootNavController.navigate(Routes.MAIN) },
+ onBack = { rootNavController.popBackStack() }
+ )
+ }
+ composable(Routes.SCHEDULE) {
+ ScheduleScreen(onBack = { rootNavController.popBackStack() })
+ }
+ composable(Routes.ORDER_DETAIL, arguments = listOf(navArgument("orderId") { type = NavType.IntType })) { backStackEntry ->
+ val orderId = backStackEntry.arguments?.getInt("orderId") ?: return@composable
+ OrderDetailScreen(orderId = orderId, onBack = { rootNavController.popBackStack() })
+ }
+ composable(Routes.ORDER_FORM, arguments = listOf(navArgument("editId") { type = NavType.IntType; defaultValue = -1 })) { backStackEntry ->
+ val editId = backStackEntry.arguments?.getInt("editId")?.takeIf { it > 0 }
+ OrderFormScreen(editOrderId = editId, onBack = { rootNavController.popBackStack() }, onSaved = { rootNavController.popBackStack() })
+ }
+ composable(Routes.WORK_ORDER_DETAIL, arguments = listOf(navArgument("id") { type = NavType.IntType })) { backStackEntry ->
+ val id = backStackEntry.arguments?.getInt("id") ?: return@composable
+ WorkOrderDetailScreen(id = id, onBack = { rootNavController.popBackStack() })
+ }
+ composable(Routes.WORK_ORDER_FORM, arguments = listOf(navArgument("editId") { type = NavType.IntType; defaultValue = -1 })) { backStackEntry ->
+ val editId = backStackEntry.arguments?.getInt("editId")?.takeIf { it > 0 }
+ WorkOrderFormScreen(editId = editId, onBack = { rootNavController.popBackStack() }, onSaved = { rootNavController.popBackStack() })
+ }
+ composable(Routes.ITEM_DETAIL, arguments = listOf(navArgument("id") { type = NavType.IntType })) { backStackEntry ->
+ val id = backStackEntry.arguments?.getInt("id") ?: return@composable
+ ItemDetailScreen(id = id, onBack = { rootNavController.popBackStack() })
+ }
+ composable(Routes.ITEM_FORM, arguments = listOf(navArgument("containerId") { type = NavType.IntType }, navArgument("editId") { type = NavType.IntType; defaultValue = -1 })) { backStackEntry ->
+ val containerId = backStackEntry.arguments?.getInt("containerId") ?: 0
+ val editId = backStackEntry.arguments?.getInt("editId")?.takeIf { it > 0 }
+ ItemFormScreen(containerId = containerId, editId = editId, onBack = { rootNavController.popBackStack() }, onSaved = { rootNavController.popBackStack() })
+ }
+ composable(Routes.CUSTOMER_LIST) {
+ CustomerListScreen(onBack = { rootNavController.popBackStack() })
+ }
+ }
+}
+
+@Composable
+fun MainScreen(onLogout: () -> Unit, onNavigate: (String) -> Unit) {
+ val tabNavController = rememberNavController()
+
+ Scaffold(
+ bottomBar = {
+ NavigationBar {
+ val navBackStackEntry by tabNavController.currentBackStackEntryAsState()
+ val currentDestination = navBackStackEntry?.destination
+ bottomNavItems.forEach { item ->
+ NavigationBarItem(
+ icon = { Icon(item.icon, contentDescription = item.label) },
+ label = { Text(item.label) },
+ selected = currentDestination?.hierarchy?.any { it.route == item.route } == true,
+ onClick = {
+ tabNavController.navigate(item.route) {
+ popUpTo(tabNavController.graph.findStartDestination().id) { saveState = true }
+ launchSingleTop = true
+ restoreState = true
+ }
+ }
+ )
+ }
+ }
+ }
+ ) { innerPadding ->
+ NavHost(navController = tabNavController, startDestination = BottomTabs.HOME, modifier = Modifier.padding(innerPadding)) {
+ composable(BottomTabs.HOME) {
+ HomeScreen(
+ onSearchClick = { onNavigate(Routes.SEARCH) },
+ onScheduleClick = { onNavigate(Routes.SCHEDULE) }
+ )
+ }
+ composable(BottomTabs.ORDERS) {
+ OrderListScreen(
+ onOrderClick = { id -> onNavigate(Routes.orderDetail(id)) },
+ onAddClick = { onNavigate(Routes.orderForm()) }
+ )
+ }
+ composable(BottomTabs.WORK_ORDERS) {
+ WorkOrderListScreen(
+ onWorkOrderClick = { id -> onNavigate(Routes.workOrderDetail(id)) },
+ onAddClick = { onNavigate(Routes.workOrderForm()) }
+ )
+ }
+ composable(BottomTabs.WAREHOUSE) {
+ WarehouseScreen(
+ onItemClick = { id -> onNavigate(Routes.itemDetail(id)) }
+ )
+ }
+ composable(BottomTabs.PROFILE) {
+ ProfileScreen(
+ onLogout = onLogout,
+ onGoToSettings = { onNavigate(Routes.SETTINGS) }
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/order/OrderDetailScreen.kt b/app/src/main/java/com/example/ops_android/ui/order/OrderDetailScreen.kt
new file mode 100644
index 0000000..d58b4b4
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/order/OrderDetailScreen.kt
@@ -0,0 +1,247 @@
+package com.example.ops_android.ui.order
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Print
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.ui.LocalAppContainer
+import kotlinx.coroutines.launch
+
+val STATUS_FLOW = listOf("pending", "ordered", "arrived", "received")
+val STATUS_FLOW_LABELS = mapOf(
+ "pending" to "待处理",
+ "ordered" to "已订购",
+ "arrived" to "已到货",
+ "received" to "已收货",
+ "lost" to "丢失",
+ "returned" to "退货"
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OrderDetailScreen(
+ orderId: Int,
+ onBack: () -> Unit
+) {
+ val repository = LocalAppContainer.current.orderRepository
+ val viewModel: OrderDetailViewModel = viewModel(
+ key = "order_$orderId",
+ factory = OrderDetailViewModel.Factory(repository, orderId)
+ )
+ val uiState by viewModel.uiState.collectAsState()
+
+ val order = uiState.order
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("订单详情") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White)
+ }
+ },
+ actions = {
+ val scope = rememberCoroutineScope()
+ order?.let { o ->
+ val printerManager = LocalAppContainer.current.printerManager
+ if (printerManager != null) {
+ IconButton(onClick = {
+ scope.launch {
+ com.example.ops_android.printer.PrintHelper.printOrderLabel(printerManager, o)
+ }
+ }) {
+ Icon(Icons.Default.Print, "打印", tint = Color.White)
+ }
+ }
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color(0xFF667EEA),
+ titleContentColor = Color.White
+ )
+ )
+ }
+ ) { padding ->
+ if (uiState.isLoading) {
+ Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ return@Scaffold
+ }
+
+ if (order == null) {
+ Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) {
+ Text(uiState.errorMessage ?: "加载失败")
+ }
+ return@Scaffold
+ }
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ item {
+ Text("基本信息", fontWeight = FontWeight.Bold, fontSize = 18.sp)
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp)) {
+ DetailRow("标题", order.title ?: "-")
+ DetailRow("状态", STATUS_FLOW_LABELS[order.status] ?: order.status ?: "-")
+ DetailRow("备注", order.remark ?: "-")
+ DetailRow("链接", order.link ?: "-")
+ DetailRow("创建时间", order.created_at ?: "-")
+ }
+ }
+ }
+
+ order.style_tags?.let { tags ->
+ if (tags.isNotEmpty()) {
+ item {
+ Text("款式标签", fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp)) {
+ tags.forEach { Text("- $it", fontSize = 14.sp) }
+ }
+ }
+ }
+ }
+ }
+
+ order.costs?.let { costs ->
+ if (costs.isNotEmpty()) {
+ item {
+ Text("费用明细", fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ Card(modifier = Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(16.dp)) {
+ costs.forEach { cost ->
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text("${cost.amount} ${cost.currency}")
+ Text(cost.remark ?: "", color = Color.Gray)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Status timeline
+ order.status_logs?.let { logs ->
+ if (logs.isNotEmpty()) {
+ item {
+ Text("状态时间线", fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ }
+ items(logs) { log ->
+ TimelineItem(log)
+ }
+ }
+ }
+
+ // Status change buttons
+ item {
+ Spacer(Modifier.height(8.dp))
+ Text("状态变更", fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ STATUS_FLOW.forEach { status ->
+ if (status != order.status) {
+ FilterChip(
+ selected = false,
+ onClick = { viewModel.showStatusDialog(status) },
+ label = { Text(STATUS_FLOW_LABELS[status] ?: status, fontSize = 12.sp) }
+ )
+ }
+ }
+ FilterChip(
+ selected = false,
+ onClick = { viewModel.showStatusDialog("lost") },
+ label = { Text("丢失", fontSize = 12.sp, color = Color(0xFFF44336)) }
+ )
+ FilterChip(
+ selected = false,
+ onClick = { viewModel.showStatusDialog("returned") },
+ label = { Text("退货", fontSize = 12.sp, color = Color(0xFF9C27B0)) }
+ )
+ }
+ }
+ }
+ }
+
+ // Status change dialog
+ if (uiState.showStatusDialog) {
+ val statusLabel = STATUS_FLOW_LABELS[uiState.selectedStatus] ?: uiState.selectedStatus
+ AlertDialog(
+ onDismissRequest = { viewModel.dismissStatusDialog() },
+ title = { Text("变更状态为「$statusLabel」") },
+ text = {
+ Column {
+ Text("请输入备注(可选):")
+ Spacer(Modifier.height(8.dp))
+ OutlinedTextField(
+ value = uiState.statusComment,
+ onValueChange = viewModel::onStatusCommentChange,
+ modifier = Modifier.fillMaxWidth(),
+ minLines = 2,
+ placeholder = { Text("备注信息") }
+ )
+ }
+ },
+ confirmButton = {
+ Button(
+ onClick = { viewModel.submitStatusChange() },
+ enabled = !uiState.isSubmitting
+ ) { Text("确认") }
+ },
+ dismissButton = {
+ TextButton(onClick = { viewModel.dismissStatusDialog() }) { Text("取消") }
+ }
+ )
+ }
+}
+
+@Composable
+private fun DetailRow(label: String, value: String) {
+ Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
+ Text(label, color = Color.Gray, modifier = Modifier.width(80.dp))
+ Text(value)
+ }
+}
+
+@Composable
+private fun TimelineItem(log: com.example.ops_android.data.model.StatusLog) {
+ Card(modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp)) {
+ Column(Modifier.padding(12.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
+ Text(
+ STATUS_FLOW_LABELS[log.status] ?: log.status ?: "",
+ fontWeight = FontWeight.Medium,
+ color = Color(0xFF667EEA)
+ )
+ Text(log.created_at ?: "", fontSize = 12.sp, color = Color.Gray)
+ }
+ log.comment?.let {
+ if (it.isNotBlank()) {
+ Spacer(Modifier.height(4.dp))
+ Text(it, fontSize = 14.sp)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/order/OrderDetailViewModel.kt b/app/src/main/java/com/example/ops_android/ui/order/OrderDetailViewModel.kt
new file mode 100644
index 0000000..19533fc
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/order/OrderDetailViewModel.kt
@@ -0,0 +1,98 @@
+package com.example.ops_android.ui.order
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.example.ops_android.data.model.Cost
+import com.example.ops_android.data.model.PurchaseOrder
+import com.example.ops_android.data.model.StatusLog
+import com.example.ops_android.data.repository.OrderRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class OrderDetailUiState(
+ val order: PurchaseOrder? = null,
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+ val showStatusDialog: Boolean = false,
+ val selectedStatus: String = "",
+ val statusComment: String = "",
+ val isSubmitting: Boolean = false
+)
+
+class OrderDetailViewModel(
+ private val orderRepository: OrderRepository,
+ private val orderId: Int
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(OrderDetailUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadOrder()
+ }
+
+ fun loadOrder() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true)
+ val result = orderRepository.getOrder(orderId)
+ result.fold(
+ onSuccess = { order ->
+ _uiState.value = _uiState.value.copy(order = order, isLoading = false)
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = e.message)
+ }
+ )
+ }
+ }
+
+ fun showStatusDialog(status: String) {
+ _uiState.value = _uiState.value.copy(
+ showStatusDialog = true,
+ selectedStatus = status,
+ statusComment = ""
+ )
+ }
+
+ fun dismissStatusDialog() {
+ _uiState.value = _uiState.value.copy(showStatusDialog = false)
+ }
+
+ fun onStatusCommentChange(comment: String) {
+ _uiState.value = _uiState.value.copy(statusComment = comment)
+ }
+
+ fun submitStatusChange() {
+ viewModelScope.launch {
+ val state = _uiState.value
+ _uiState.value = state.copy(isSubmitting = true)
+ val result = orderRepository.updateOrderStatus(
+ id = orderId,
+ status = state.selectedStatus,
+ comment = state.statusComment.ifBlank { null }
+ )
+ result.fold(
+ onSuccess = {
+ _uiState.value = _uiState.value.copy(showStatusDialog = false, isSubmitting = false)
+ loadOrder()
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(isSubmitting = false, errorMessage = e.message)
+ }
+ )
+ }
+ }
+
+ class Factory(
+ private val orderRepository: OrderRepository,
+ private val orderId: Int
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return OrderDetailViewModel(orderRepository, orderId) as T
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/order/OrderFormScreen.kt b/app/src/main/java/com/example/ops_android/ui/order/OrderFormScreen.kt
new file mode 100644
index 0000000..e26ed6e
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/order/OrderFormScreen.kt
@@ -0,0 +1,198 @@
+package com.example.ops_android.ui.order
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.data.repository.OrderRepository
+import com.example.ops_android.ui.LocalAppContainer
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class OrderFormUiState(
+ val title: String = "",
+ val remark: String = "",
+ val link: String = "",
+ val styleTags: String = "",
+ val costs: List> = listOf("" to ""),
+ val isSubmitting: Boolean = false,
+ val errorMessage: String? = null,
+ val success: Boolean = false
+)
+
+class OrderFormViewModel(
+ private val orderRepository: OrderRepository,
+ private val editOrderId: Int? = null
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(OrderFormUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun onTitleChange(v: String) { _uiState.value = _uiState.value.copy(title = v) }
+ fun onRemarkChange(v: String) { _uiState.value = _uiState.value.copy(remark = v) }
+ fun onLinkChange(v: String) { _uiState.value = _uiState.value.copy(link = v) }
+ fun onStyleTagsChange(v: String) { _uiState.value = _uiState.value.copy(styleTags = v) }
+
+ fun addCost() {
+ _uiState.value = _uiState.value.copy(costs = _uiState.value.costs + ("" to ""))
+ }
+
+ fun updateCostAmount(index: Int, value: String) {
+ val costs = _uiState.value.costs.toMutableList()
+ costs[index] = costs[index].copy(first = value)
+ _uiState.value = _uiState.value.copy(costs = costs)
+ }
+
+ fun updateCostCurrency(index: Int, value: String) {
+ val costs = _uiState.value.costs.toMutableList()
+ costs[index] = costs[index].copy(second = value)
+ _uiState.value = _uiState.value.copy(costs = costs)
+ }
+
+ fun removeCost(index: Int) {
+ val costs = _uiState.value.costs.toMutableList()
+ if (costs.size > 1) {
+ costs.removeAt(index)
+ _uiState.value = _uiState.value.copy(costs = costs)
+ }
+ }
+
+ fun submit() {
+ val state = _uiState.value
+ if (state.title.isBlank()) {
+ _uiState.value = state.copy(errorMessage = "标题不能为空")
+ return
+ }
+
+ val costList = state.costs
+ .filter { it.first.isNotBlank() }
+ .map { mapOf("amount" to it.first.toDoubleOrNull(), "currency" to it.second.ifBlank { "CNY" }) }
+
+ val tags = state.styleTags
+ .split(",", ",")
+ .map { it.trim() }
+ .filter { it.isNotBlank() }
+
+ val data = mapOf(
+ "title" to state.title,
+ "remark" to state.remark,
+ "link" to state.link,
+ "style_tags" to tags,
+ "costs" to costList
+ )
+
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isSubmitting = true, errorMessage = null)
+ val result = if (editOrderId != null) {
+ orderRepository.updateOrder(data + ("id" to editOrderId))
+ } else {
+ orderRepository.addOrder(data)
+ }
+ result.fold(
+ onSuccess = { _uiState.value = _uiState.value.copy(isSubmitting = false, success = true) },
+ onFailure = { e -> _uiState.value = _uiState.value.copy(isSubmitting = false, errorMessage = e.message) }
+ )
+ }
+ }
+
+ class Factory(
+ private val orderRepository: OrderRepository,
+ private val editOrderId: Int? = null
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return OrderFormViewModel(orderRepository, editOrderId) as T
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OrderFormScreen(
+ editOrderId: Int? = null,
+ onBack: () -> Unit,
+ onSaved: () -> Unit
+) {
+ val viewModel: OrderFormViewModel = viewModel(
+ factory = OrderFormViewModel.Factory(LocalAppContainer.current.orderRepository, editOrderId)
+ )
+ val uiState by viewModel.uiState.collectAsState()
+
+ LaunchedEffect(uiState.success) {
+ if (uiState.success) onSaved()
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(if (editOrderId != null) "编辑订单" else "新建订单") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White)
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedTextField(value = uiState.title, onValueChange = viewModel::onTitleChange, label = { Text("标题 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(value = uiState.remark, onValueChange = viewModel::onRemarkChange, label = { Text("备注") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
+ OutlinedTextField(value = uiState.link, onValueChange = viewModel::onLinkChange, label = { Text("链接") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(value = uiState.styleTags, onValueChange = viewModel::onStyleTagsChange, label = { Text("款式标签(逗号分隔)") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+
+ Text("费用明细", fontWeight = FontWeight.Bold, fontSize = 16.sp)
+
+ uiState.costs.forEachIndexed { index, cost ->
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ OutlinedTextField(
+ value = cost.first, onValueChange = { viewModel.updateCostAmount(index, it) },
+ label = { Text("金额") }, modifier = Modifier.weight(1f), singleLine = true
+ )
+ OutlinedTextField(
+ value = cost.second, onValueChange = { viewModel.updateCostCurrency(index, it) },
+ label = { Text("币种") }, modifier = Modifier.weight(0.5f), singleLine = true,
+ placeholder = { Text("CNY") }
+ )
+ if (uiState.costs.size > 1) {
+ IconButton(onClick = { viewModel.removeCost(index) }) {
+ Text("✕", color = Color.Red)
+ }
+ }
+ }
+ }
+
+ TextButton(onClick = { viewModel.addCost() }) { Text("+ 添加费用") }
+
+ uiState.errorMessage?.let {
+ Text(it, color = Color.Red, fontSize = 14.sp)
+ }
+
+ Button(
+ onClick = { viewModel.submit() },
+ modifier = Modifier.fillMaxWidth().height(48.dp),
+ enabled = !uiState.isSubmitting
+ ) {
+ if (uiState.isSubmitting) CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp)
+ else Text("提交")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/order/OrderListScreen.kt b/app/src/main/java/com/example/ops_android/ui/order/OrderListScreen.kt
new file mode 100644
index 0000000..d1e0753
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/order/OrderListScreen.kt
@@ -0,0 +1,170 @@
+package com.example.ops_android.ui.order
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.data.model.PurchaseOrder
+import com.example.ops_android.ui.LocalAppContainer
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OrderListScreen(
+ onOrderClick: (Int) -> Unit,
+ onAddClick: () -> Unit,
+ viewModel: OrderListViewModel = viewModel(factory = OrderListViewModel.Factory(LocalAppContainer.current.orderRepository))
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("采购订单") },
+ actions = {
+ IconButton(onClick = onAddClick) {
+ Icon(Icons.Default.Add, "新建", tint = Color.White)
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color(0xFF667EEA),
+ titleContentColor = Color.White
+ )
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = onAddClick,
+ containerColor = Color(0xFF667EEA)
+ ) {
+ Icon(Icons.Default.Add, "新建", tint = Color.White)
+ }
+ }
+ ) { padding ->
+ Column(modifier = Modifier.padding(padding)) {
+ // Status filter tabs
+ ScrollableTabRow(
+ selectedTabIndex = ORDER_STATUSES.indexOfFirst { it.first == uiState.selectedStatus }.coerceAtLeast(0),
+ modifier = Modifier.fillMaxWidth(),
+ edgePadding = 8.dp
+ ) {
+ ORDER_STATUSES.forEachIndexed { index, (status, label) ->
+ Tab(
+ selected = uiState.selectedStatus == status,
+ onClick = { viewModel.selectStatus(status) },
+ text = {
+ Text(
+ label,
+ fontSize = 13.sp,
+ fontWeight = if (uiState.selectedStatus == status) FontWeight.Bold else FontWeight.Normal
+ )
+ }
+ )
+ }
+ }
+
+ when {
+ uiState.isLoading -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ }
+ uiState.errorMessage != null -> {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(uiState.errorMessage!!, color = Color.Red)
+ Spacer(Modifier.height(8.dp))
+ Button(onClick = { viewModel.refresh() }) { Text("重试") }
+ }
+ }
+ }
+ else -> {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ items(uiState.orders, key = { it.id }) { order ->
+ OrderListItem(
+ order = order,
+ onClick = { onOrderClick(order.id) }
+ )
+ }
+
+ if (uiState.isLoadingMore) {
+ item {
+ Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(modifier = Modifier.size(24.dp))
+ }
+ }
+ }
+
+ // infinite scroll trigger
+ item {
+ LaunchedEffect(Unit) { viewModel.loadMore() }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun OrderListItem(order: PurchaseOrder, onClick: () -> Unit) {
+ val statusColors = mapOf(
+ "pending" to Color(0xFFFF9800),
+ "ordered" to Color(0xFF2196F3),
+ "arrived" to Color(0xFF03A9F4),
+ "received" to Color(0xFF4CAF50),
+ "lost" to Color(0xFFF44336),
+ "returned" to Color(0xFF9C27B0)
+ )
+ val statusLabels = mapOf(
+ "pending" to "待处理",
+ "ordered" to "已订购",
+ "arrived" to "已到货",
+ "received" to "已收货",
+ "lost" to "丢失",
+ "returned" to "退货"
+ )
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { onClick() }
+ ) {
+ Row(
+ modifier = Modifier.padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(order.title ?: "无标题", fontWeight = FontWeight.Medium, fontSize = 15.sp)
+ Spacer(Modifier.height(4.dp))
+ Text(order.created_at ?: "", fontSize = 12.sp, color = Color.Gray)
+ }
+ Spacer(Modifier.width(8.dp))
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = statusColors[order.status]?.copy(alpha = 0.15f) ?: Color.Gray.copy(alpha = 0.15f)
+ ) {
+ Text(
+ statusLabels[order.status] ?: order.status ?: "",
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ fontSize = 12.sp,
+ color = statusColors[order.status] ?: Color.Gray
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/order/OrderListViewModel.kt b/app/src/main/java/com/example/ops_android/ui/order/OrderListViewModel.kt
new file mode 100644
index 0000000..4db4ec4
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/order/OrderListViewModel.kt
@@ -0,0 +1,143 @@
+package com.example.ops_android.ui.order
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.example.ops_android.data.model.PurchaseOrder
+import com.example.ops_android.data.repository.OrderRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+val ORDER_STATUSES = listOf(
+ "" to "全部",
+ "pending" to "待处理",
+ "ordered" to "已订购",
+ "arrived" to "已到货",
+ "received" to "已收货",
+ "lost" to "丢失",
+ "returned" to "退货"
+)
+
+data class OrderListUiState(
+ val orders: List = emptyList(),
+ val selectedStatus: String = "",
+ val isLoading: Boolean = false,
+ val isRefreshing: Boolean = false,
+ val isLoadingMore: Boolean = false,
+ val hasMore: Boolean = true,
+ val errorMessage: String? = null
+)
+
+class OrderListViewModel(
+ private val orderRepository: OrderRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(OrderListUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val pageSize = 20
+ private var currentOffset = 0
+
+ init {
+ loadOrders()
+ }
+
+ fun selectStatus(status: String) {
+ _uiState.value = _uiState.value.copy(selectedStatus = status)
+ refresh()
+ }
+
+ fun refresh() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isRefreshing = true, orders = emptyList())
+ currentOffset = 0
+ val state = _uiState.value
+ val result = orderRepository.getOrders(
+ status = state.selectedStatus.ifEmpty { null },
+ offset = 0,
+ limit = pageSize
+ )
+ result.fold(
+ onSuccess = { (orders, total) ->
+ _uiState.value = _uiState.value.copy(
+ orders = orders,
+ isRefreshing = false,
+ isLoading = false,
+ hasMore = orders.size >= pageSize,
+ errorMessage = null
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isRefreshing = false,
+ isLoading = false,
+ errorMessage = e.message
+ )
+ }
+ )
+ }
+ }
+
+ fun loadMore() {
+ val state = _uiState.value
+ if (state.isLoadingMore || !state.hasMore) return
+
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoadingMore = true)
+ currentOffset += pageSize
+ val result = orderRepository.getOrders(
+ status = state.selectedStatus.ifEmpty { null },
+ offset = currentOffset,
+ limit = pageSize
+ )
+ result.fold(
+ onSuccess = { (orders, total) ->
+ _uiState.value = _uiState.value.copy(
+ orders = _uiState.value.orders + orders,
+ isLoadingMore = false,
+ hasMore = orders.size >= pageSize
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isLoadingMore = false,
+ errorMessage = e.message
+ )
+ }
+ )
+ }
+ }
+
+ private fun loadOrders() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true)
+ val result = orderRepository.getOrders(limit = pageSize)
+ result.fold(
+ onSuccess = { (orders, total) ->
+ _uiState.value = _uiState.value.copy(
+ orders = orders,
+ isLoading = false,
+ hasMore = orders.size >= pageSize
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isLoading = false,
+ errorMessage = e.message
+ )
+ }
+ )
+ }
+ }
+
+ class Factory(
+ private val orderRepository: OrderRepository
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return OrderListViewModel(orderRepository) as T
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/profile/ProfileScreen.kt b/app/src/main/java/com/example/ops_android/ui/profile/ProfileScreen.kt
new file mode 100644
index 0000000..008842b
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/profile/ProfileScreen.kt
@@ -0,0 +1,166 @@
+package com.example.ops_android.ui.profile
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.automirrored.filled.Logout
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.ui.LocalAppContainer
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ProfileScreen(
+ onLogout: () -> Unit,
+ onGoToSettings: () -> Unit,
+ viewModel: ProfileViewModel = viewModel(factory = ProfileViewModel.Factory(
+ LocalAppContainer.current.authRepository,
+ LocalAppContainer.current.sessionManager
+ ))
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ var showLogoutDialog by remember { mutableStateOf(false) }
+
+ LaunchedEffect(uiState.isLoggedOut) {
+ if (uiState.isLoggedOut) {
+ onLogout()
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("我的") },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color(0xFF667EEA),
+ titleContentColor = Color.White
+ )
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(200.dp)
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(Color(0xFF667EEA), Color(0xFF764BA2))
+ )
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Box(
+ modifier = Modifier
+ .size(80.dp)
+ .clip(CircleShape)
+ .background(Color.White.copy(alpha = 0.3f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ Icons.Default.Person,
+ contentDescription = null,
+ modifier = Modifier.size(48.dp),
+ tint = Color.White
+ )
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ Text(
+ text = viewModel.username.ifEmpty { "未登录" },
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ color = Color.White
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ ) {
+ Column(modifier = Modifier.padding(16.dp)) {
+ ProfileInfoRow("用户名", viewModel.username)
+ ProfileInfoRow("姓名", uiState.userInfo?.FirstName ?: "-")
+ ProfileInfoRow("角色", uiState.userInfo?.Role ?: "-")
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ NavigationDrawerItem(
+ icon = { Icon(Icons.Default.Settings, contentDescription = null) },
+ label = { Text("设置") },
+ selected = false,
+ onClick = onGoToSettings,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+
+ NavigationDrawerItem(
+ icon = { Icon(Icons.AutoMirrored.Filled.Logout, contentDescription = null, tint = Color(0xFFF44336)) },
+ label = { Text("退出登录", color = Color(0xFFF44336)) },
+ selected = false,
+ onClick = { showLogoutDialog = true },
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
+ }
+
+ if (showLogoutDialog) {
+ AlertDialog(
+ onDismissRequest = { showLogoutDialog = false },
+ title = { Text("退出登录") },
+ text = { Text("确定要退出登录吗?") },
+ confirmButton = {
+ TextButton(onClick = {
+ showLogoutDialog = false
+ viewModel.logout()
+ }) {
+ Text("确定", color = Color(0xFFF44336))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showLogoutDialog = false }) {
+ Text("取消")
+ }
+ }
+ )
+ }
+}
+
+@Composable
+private fun ProfileInfoRow(label: String, value: String) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 8.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(text = label, color = Color.Gray)
+ Text(text = value, fontWeight = FontWeight.Medium)
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/profile/ProfileViewModel.kt b/app/src/main/java/com/example/ops_android/ui/profile/ProfileViewModel.kt
new file mode 100644
index 0000000..6462989
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/profile/ProfileViewModel.kt
@@ -0,0 +1,84 @@
+package com.example.ops_android.ui.profile
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.example.ops_android.data.local.SessionManager
+import com.example.ops_android.data.model.UserDetail
+import com.example.ops_android.data.model.UserInfo
+import com.example.ops_android.data.repository.AuthRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class ProfileUiState(
+ val user: UserInfo? = null,
+ val userInfo: UserDetail? = null,
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+ val isLoggedOut: Boolean = false
+)
+
+class ProfileViewModel(
+ private val authRepository: AuthRepository,
+ private val sessionManager: SessionManager
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(ProfileUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ init {
+ loadProfile()
+ }
+
+ fun loadProfile() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true)
+ val result = authRepository.getUserInfo()
+ result.fold(
+ onSuccess = { (user, userInfo) ->
+ _uiState.value = _uiState.value.copy(
+ user = user,
+ userInfo = userInfo,
+ isLoading = false,
+ errorMessage = null
+ )
+ },
+ onFailure = { e ->
+ _uiState.value = _uiState.value.copy(
+ isLoading = false,
+ errorMessage = e.message ?: "加载失败"
+ )
+ }
+ )
+ }
+ }
+
+ fun logout() {
+ viewModelScope.launch {
+ authRepository.logout()
+ _uiState.value = _uiState.value.copy(isLoggedOut = true)
+ }
+ }
+
+ val username: String
+ get() {
+ val info = _uiState.value.userInfo
+ val user = _uiState.value.user
+ return info?.Username ?: user?.Name ?: ""
+ }
+
+ val avatarPath: String?
+ get() = _uiState.value.userInfo?.AvatarPath
+
+ class Factory(
+ private val authRepository: AuthRepository,
+ private val sessionManager: SessionManager
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return ProfileViewModel(authRepository, sessionManager) as T
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleScreen.kt b/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleScreen.kt
new file mode 100644
index 0000000..2c866aa
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleScreen.kt
@@ -0,0 +1,158 @@
+package com.example.ops_android.ui.schedule
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.ui.LocalAppContainer
+import java.text.SimpleDateFormat
+import java.util.*
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ScheduleScreen(
+ onBack: () -> Unit
+) {
+ val vm: ScheduleViewModel = viewModel(factory = ScheduleViewModel.Factory(LocalAppContainer.current.scheduleRepository))
+ val s by vm.uiState.collectAsState()
+ val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("日程管理") },
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } },
+ actions = { IconButton(onClick = { vm.showAddForm(dateFormat.format(Date())) }) { Icon(Icons.Default.Add, "添加", tint = Color.White) } },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)
+ )
+ }
+ ) { padding ->
+ LazyColumn(Modifier.fillMaxSize().padding(padding)) {
+ // Month header
+ item {
+ Card(Modifier.fillMaxWidth().padding(12.dp)) {
+ Column(Modifier.padding(8.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = { vm.prevMonth() }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "上月") }
+ Text("${s.currentYear}年 ${s.currentMonth + 1}月", fontWeight = FontWeight.Bold, fontSize = 18.sp)
+ IconButton(onClick = { vm.nextMonth() }) { Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "下月") }
+ }
+
+ // Weekday headers
+ Row(Modifier.fillMaxWidth()) {
+ listOf("日", "一", "二", "三", "四", "五", "六").forEach { day ->
+ Text(day, Modifier.weight(1f), textAlign = TextAlign.Center, fontSize = 12.sp, color = Color.Gray)
+ }
+ }
+ Spacer(Modifier.height(4.dp))
+
+ // Calendar grid
+ val cal = Calendar.getInstance().apply { set(s.currentYear, s.currentMonth, 1) }
+ val firstDayOfWeek = cal.get(Calendar.DAY_OF_WEEK) - 1
+ val daysInMonth = cal.getActualMaximum(Calendar.DAY_OF_MONTH)
+ val today = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date())
+
+ var dayCount = 0
+ val weeks = mutableListOf>()
+ var currentWeek = mutableListOf()
+ for (i in 0 until firstDayOfWeek) { currentWeek.add(null); dayCount++ }
+ for (day in 1..daysInMonth) { currentWeek.add(day); dayCount++; if (dayCount % 7 == 0) { weeks.add(currentWeek.toList()); currentWeek = mutableListOf() } }
+ if (currentWeek.isNotEmpty()) { while (currentWeek.size < 7) currentWeek.add(null); weeks.add(currentWeek.toList()) }
+
+ weeks.forEach { week ->
+ Row(Modifier.fillMaxWidth()) {
+ week.forEach { day ->
+ val cellModifier = Modifier.weight(1f).aspectRatio(1f).padding(2.dp)
+ if (day != null) {
+ val dateStr = String.format("%04d-%02d-%02d", s.currentYear, s.currentMonth + 1, day)
+ val hasEvent = s.events.any { it.start_time?.startsWith(dateStr) == true }
+ val isToday = dateStr == today
+
+ Box(
+ cellModifier.clip(CircleShape)
+ .then(if (isToday) Modifier.background(Color(0xFF667EEA)) else Modifier)
+ .clickable { vm.showAddForm(dateStr) },
+ contentAlignment = Alignment.Center
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(day.toString(), fontSize = 13.sp, color = if (isToday) Color.White else Color.Black)
+ if (hasEvent) Box(Modifier.size(4.dp).clip(CircleShape).background(if (isToday) Color.White else Color(0xFF667EEA)))
+ }
+ }
+ } else {
+ Spacer(cellModifier)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Events list
+ item { Text("日程列表", fontWeight = FontWeight.Bold, fontSize = 16.sp, modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) }
+
+ if (s.events.isEmpty()) {
+ item { Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { Text("暂无日程", color = Color.Gray) } }
+ } else {
+ items(s.events.sortedBy { it.start_time }) { event ->
+ Card(Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp).clickable { vm.showEditForm(event) }) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(event.title ?: "", fontWeight = FontWeight.Medium)
+ val timeStr = listOfNotNull(event.start_time?.substring(11)?.take(5), event.end_time?.substring(11)?.take(5)).joinToString(" ~ ")
+ if (timeStr.isNotBlank()) Text(timeStr, fontSize = 12.sp, color = Color.Gray)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Form dialog
+ if (s.showForm) AlertDialog(
+ onDismissRequest = { vm.dismissForm() },
+ title = { Text(if (s.editingEvent != null) "编辑日程" else "添加日程") },
+ text = {
+ Column(Modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(s.formTitle, vm::onFormTitle, label = { Text("标题 *") }, singleLine = true, modifier = Modifier.fillMaxWidth())
+ OutlinedTextField(s.formDescription, vm::onFormDesc, label = { Text("描述") }, minLines = 2, modifier = Modifier.fillMaxWidth())
+ Text("日期: ${s.formDate}", fontSize = 14.sp, color = Color.Gray)
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(s.formStartTime, vm::onFormStartTime, label = { Text("开始") }, placeholder = { Text("HH:mm") }, singleLine = true, modifier = Modifier.weight(1f))
+ OutlinedTextField(s.formEndTime, vm::onFormEndTime, label = { Text("结束") }, placeholder = { Text("HH:mm") }, singleLine = true, modifier = Modifier.weight(1f))
+ }
+ }
+ },
+ confirmButton = {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (s.editingEvent != null) {
+ TextButton(onClick = { vm.deleteEvent(s.editingEvent!!.id); vm.dismissForm() }) { Text("删除", color = Color.Red) }
+ }
+ Button(onClick = { vm.submitForm() }, enabled = !s.isSubmitting) { Text("保存") }
+ }
+ },
+ dismissButton = { TextButton(onClick = { vm.dismissForm() }) { Text("取消") } }
+ )
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleViewModel.kt b/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleViewModel.kt
new file mode 100644
index 0000000..7c885f9
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/schedule/ScheduleViewModel.kt
@@ -0,0 +1,113 @@
+package com.example.ops_android.ui.schedule
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.example.ops_android.data.model.ScheduleEvent
+import com.example.ops_android.data.repository.ScheduleRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import java.text.SimpleDateFormat
+import java.util.*
+
+data class ScheduleUiState(
+ val events: List = emptyList(),
+ val currentYear: Int = Calendar.getInstance().get(Calendar.YEAR),
+ val currentMonth: Int = Calendar.getInstance().get(Calendar.MONTH),
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+ val showForm: Boolean = false,
+ val editingEvent: ScheduleEvent? = null,
+ val formTitle: String = "",
+ val formDescription: String = "",
+ val formDate: String = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date()),
+ val formStartTime: String = "",
+ val formEndTime: String = "",
+ val isSubmitting: Boolean = false
+)
+
+class ScheduleViewModel(
+ private val repository: ScheduleRepository
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(ScheduleUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+ private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
+
+ init { loadEvents() }
+
+ fun loadEvents() {
+ val state = _uiState.value
+ val cal = Calendar.getInstance()
+ cal.set(state.currentYear, state.currentMonth, 1)
+ val start = dateFormat.format(cal.time)
+ cal.set(Calendar.DAY_OF_MONTH, cal.getActualMaximum(Calendar.DAY_OF_MONTH))
+ val end = dateFormat.format(cal.time)
+
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true)
+ repository.getEvents(start, end).fold(
+ onSuccess = { _uiState.value = _uiState.value.copy(events = it, isLoading = false) },
+ onFailure = { _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = it.message) }
+ )
+ }
+ }
+
+ fun prevMonth() {
+ val state = _uiState.value
+ _uiState.value = state.copy(
+ currentMonth = if (state.currentMonth == 0) { _uiState.value = _uiState.value.copy(currentYear = state.currentYear - 1); 11 } else state.currentMonth - 1
+ )
+ loadEvents()
+ }
+
+ fun nextMonth() {
+ val state = _uiState.value
+ _uiState.value = state.copy(
+ currentMonth = if (state.currentMonth == 11) { _uiState.value = _uiState.value.copy(currentYear = state.currentYear + 1); 0 } else state.currentMonth + 1
+ )
+ loadEvents()
+ }
+
+ fun showAddForm(date: String) {
+ _uiState.value = _uiState.value.copy(showForm = true, editingEvent = null, formTitle = "", formDescription = "", formDate = date, formStartTime = "", formEndTime = "")
+ }
+
+ fun showEditForm(event: ScheduleEvent) {
+ _uiState.value = _uiState.value.copy(
+ showForm = true, editingEvent = event,
+ formTitle = event.title ?: "", formDescription = event.description ?: "",
+ formDate = event.start_time?.take(10) ?: "", formStartTime = event.start_time?.substring(11)?.take(5) ?: "",
+ formEndTime = event.end_time?.substring(11)?.take(5) ?: ""
+ )
+ }
+
+ fun dismissForm() { _uiState.value = _uiState.value.copy(showForm = false) }
+ fun onFormTitle(v: String) { _uiState.value = _uiState.value.copy(formTitle = v) }
+ fun onFormDesc(v: String) { _uiState.value = _uiState.value.copy(formDescription = v) }
+ fun onFormStartTime(v: String) { _uiState.value = _uiState.value.copy(formStartTime = v) }
+ fun onFormEndTime(v: String) { _uiState.value = _uiState.value.copy(formEndTime = v) }
+
+ fun submitForm() {
+ val st = _uiState.value
+ if (st.formTitle.isBlank()) { _uiState.value = st.copy(errorMessage = "标题不能为空"); return }
+ val startTime = if (st.formStartTime.isNotBlank()) "${st.formDate}T${st.formStartTime}:00" else st.formDate
+ val endTime = if (st.formEndTime.isNotBlank()) "${st.formDate}T${st.formEndTime}:00" else st.formDate
+ val data = mapOf("title" to st.formTitle, "description" to st.formDescription, "start_time" to startTime, "end_time" to endTime)
+
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isSubmitting = true)
+ val r = if (st.editingEvent != null) repository.editEvent(data + ("id" to st.editingEvent.id)) else repository.addEvent(data)
+ r.fold(onSuccess = { _uiState.value = _uiState.value.copy(showForm = false, isSubmitting = false); loadEvents() }, onFailure = { _uiState.value = _uiState.value.copy(isSubmitting = false, errorMessage = it.message) })
+ }
+ }
+
+ fun deleteEvent(id: Int) {
+ viewModelScope.launch { repository.deleteEvent(id).fold(onSuccess = { loadEvents() }, onFailure = { _uiState.value = _uiState.value.copy(errorMessage = it.message) }) }
+ }
+
+ class Factory(private val repo: ScheduleRepository) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = ScheduleViewModel(repo) as T
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/search/SearchScreen.kt b/app/src/main/java/com/example/ops_android/ui/search/SearchScreen.kt
new file mode 100644
index 0000000..610e49f
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/search/SearchScreen.kt
@@ -0,0 +1,321 @@
+package com.example.ops_android.ui.search
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.util.Size
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.camera.core.CameraSelector
+import androidx.camera.core.ImageAnalysis
+import androidx.camera.core.ImageProxy
+import androidx.camera.lifecycle.ProcessCameraProvider
+import androidx.camera.view.PreviewView
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.QrCodeScanner
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.data.model.Container
+import com.example.ops_android.data.model.Item
+import com.example.ops_android.data.model.PurchaseOrder
+import com.example.ops_android.data.model.WorkOrder
+import com.example.ops_android.ui.LocalAppContainer
+import com.google.mlkit.vision.barcode.BarcodeScanner
+import com.google.mlkit.vision.barcode.BarcodeScanning
+import com.google.mlkit.vision.barcode.common.Barcode
+import com.google.mlkit.vision.common.InputImage
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SearchScreen(
+ onOrderClick: (Int) -> Unit,
+ onWorkOrderClick: (Int) -> Unit,
+ onItemClick: (Int) -> Unit,
+ onContainerClick: (Int) -> Unit,
+ onBack: () -> Unit
+) {
+ val appContainer = LocalAppContainer.current
+ val viewModel: SearchViewModel = viewModel(
+ factory = SearchViewModel.Factory(appContainer.orderRepository, appContainer.workOrderRepository, appContainer.warehouseRepository)
+ )
+ val uiState by viewModel.uiState.collectAsState()
+ var showScanner by remember { mutableStateOf(false) }
+ val context = LocalContext.current
+ var hasCameraPermission by remember { mutableStateOf(false) }
+
+ val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ hasCameraPermission = granted
+ if (granted) showScanner = true
+ }
+
+ fun checkCameraAndScan() {
+ if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
+ hasCameraPermission = true
+ showScanner = true
+ } else {
+ permissionLauncher.launch(Manifest.permission.CAMERA)
+ }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("搜索") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White)
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color(0xFF667EEA),
+ titleContentColor = Color.White
+ )
+ )
+ }
+ ) { padding ->
+ if (showScanner) {
+ BarcodeScannerView(
+ onBarcodeScanned = { barcode ->
+ viewModel.onBarcodeScanned(barcode)
+ showScanner = false
+ },
+ onClose = { showScanner = false }
+ )
+ return@Scaffold
+ }
+
+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
+ // Search bar
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ OutlinedTextField(
+ value = uiState.query,
+ onValueChange = viewModel::onQueryChange,
+ modifier = Modifier.weight(1f),
+ placeholder = { Text("输入关键词搜索...", fontSize = 14.sp) },
+ singleLine = true,
+ trailingIcon = {
+ IconButton(onClick = { viewModel.search() }) {
+ Icon(Icons.Default.Search, "搜索")
+ }
+ },
+ shape = RoundedCornerShape(24.dp)
+ )
+ IconButton(onClick = { checkCameraAndScan() }) {
+ Icon(Icons.Default.QrCodeScanner, "扫码", tint = Color(0xFF667EEA))
+ }
+ }
+
+ // Prefix hints
+ Text(
+ "前缀快捷搜索: po:采购单 wo:工单 item:物品 warehouse:容器",
+ fontSize = 11.sp,
+ color = Color.Gray,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ textAlign = TextAlign.Center
+ )
+
+ // Category tabs
+ ScrollableTabRow(
+ selectedTabIndex = SearchCategory.entries.indexOf(uiState.category),
+ modifier = Modifier.fillMaxWidth(),
+ edgePadding = 8.dp
+ ) {
+ SearchCategory.entries.forEach { cat ->
+ Tab(
+ selected = uiState.category == cat,
+ onClick = { viewModel.selectCategory(cat); viewModel.clearResults() },
+ text = { Text(cat.label, fontSize = 13.sp) }
+ )
+ }
+ }
+
+ if (uiState.isSearching) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CircularProgressIndicator()
+ }
+ return@Column
+ }
+
+ // Results
+ LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ val hasResults = uiState.orders.isNotEmpty() || uiState.workOrders.isNotEmpty() || uiState.items.isNotEmpty() || uiState.containers.isNotEmpty()
+
+ if (!hasResults && uiState.query.isNotBlank()) {
+ item {
+ Box(Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
+ Text("未找到匹配结果", color = Color.Gray)
+ }
+ }
+ }
+
+ if (uiState.orders.isNotEmpty() && (uiState.category == SearchCategory.ALL || uiState.category == SearchCategory.ORDER)) {
+ item { CategoryHeader("采购订单") }
+ items(uiState.orders.take(5)) { order -> SearchOrderItem(order) { onOrderClick(order.id) } }
+ }
+
+ if (uiState.workOrders.isNotEmpty() && (uiState.category == SearchCategory.ALL || uiState.category == SearchCategory.WORK_ORDER)) {
+ item { CategoryHeader("工单") }
+ items(uiState.workOrders.take(5)) { wo -> SearchWorkOrderItem(wo) { onWorkOrderClick(wo.id) } }
+ }
+
+ if (uiState.items.isNotEmpty() && (uiState.category == SearchCategory.ALL || uiState.category == SearchCategory.ITEM)) {
+ item { CategoryHeader("物品") }
+ items(uiState.items.take(5)) { item -> SearchItemItem(item) { onItemClick(item.id) } }
+ }
+
+ if (uiState.containers.isNotEmpty() && (uiState.category == SearchCategory.ALL || uiState.category == SearchCategory.CONTAINER)) {
+ item { CategoryHeader("容器") }
+ items(uiState.containers.take(5)) { container -> SearchContainerItem(container) { onContainerClick(container.id) } }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CategoryHeader(title: String) {
+ Text(title, fontWeight = FontWeight.Bold, fontSize = 14.sp, color = Color(0xFF667EEA), modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp))
+}
+
+@Composable
+private fun SearchOrderItem(order: PurchaseOrder, onClick: () -> Unit) {
+ Card(Modifier.fillMaxWidth().clickable { onClick() }) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(order.title ?: "无标题", fontWeight = FontWeight.Medium)
+ order.remark?.takeIf { it.isNotBlank() }?.let { Text(it, fontSize = 12.sp, color = Color.Gray, maxLines = 1) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SearchWorkOrderItem(wo: WorkOrder, onClick: () -> Unit) {
+ Card(Modifier.fillMaxWidth().clickable { onClick() }) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(wo.title ?: "无标题", fontWeight = FontWeight.Medium)
+ wo.description?.takeIf { it.isNotBlank() }?.let { Text(it, fontSize = 12.sp, color = Color.Gray, maxLines = 1) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SearchItemItem(item: Item, onClick: () -> Unit) {
+ Card(Modifier.fillMaxWidth().clickable { onClick() }) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(item.name ?: "未命名", fontWeight = FontWeight.Medium)
+ item.serial_number?.let { Text("编号: $it", fontSize = 12.sp, color = Color.Gray) }
+ }
+ }
+ }
+}
+
+@Composable
+private fun SearchContainerItem(container: Container, onClick: () -> Unit) {
+ Card(Modifier.fillMaxWidth().clickable { onClick() }) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(container.name ?: "未命名", fontWeight = FontWeight.Medium)
+ container.barcode?.let { Text("条码: $it", fontSize = 12.sp, color = Color.Gray) }
+ }
+ }
+ }
+}
+
+@Composable
+fun BarcodeScannerView(
+ onBarcodeScanned: (String) -> Unit,
+ onClose: () -> Unit
+) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ var hasScanned by remember { mutableStateOf(false) }
+
+ Box(modifier = Modifier.fillMaxSize().background(Color.Black)) {
+ AndroidView(
+ factory = { ctx ->
+ PreviewView(ctx).also { previewView ->
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
+ cameraProviderFuture.addListener({
+ val cameraProvider = cameraProviderFuture.get()
+ val preview = androidx.camera.core.Preview.Builder().build().also {
+ it.setSurfaceProvider(previewView.surfaceProvider)
+ }
+
+ val scanner = BarcodeScanning.getClient()
+ val imageAnalysis = ImageAnalysis.Builder()
+ .setTargetResolution(Size(1280, 720))
+ .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
+ .build()
+ imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(ctx)) { imageProxy ->
+ if (!hasScanned) {
+ processImageProxy(scanner, imageProxy) { barcode ->
+ hasScanned = true
+ onBarcodeScanned(barcode)
+ }
+ } else {
+ imageProxy.close()
+ }
+ }
+
+ val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
+ try {
+ cameraProvider.unbindAll()
+ cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
+ } catch (e: Exception) { }
+ }, ContextCompat.getMainExecutor(ctx))
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+
+ Button(
+ onClick = onClose,
+ modifier = Modifier.align(Alignment.TopEnd).padding(16.dp)
+ ) {
+ Text("关闭")
+ }
+ }
+}
+
+private fun processImageProxy(scanner: BarcodeScanner, imageProxy: ImageProxy, onResult: (String) -> Unit) {
+ val mediaImage = imageProxy.image
+ if (mediaImage != null) {
+ val image = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
+ scanner.process(image)
+ .addOnSuccessListener { barcodes ->
+ for (barcode in barcodes) {
+ barcode.rawValue?.let { onResult(it) }
+ }
+ }
+ .addOnCompleteListener { imageProxy.close() }
+ } else {
+ imageProxy.close()
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/search/SearchViewModel.kt b/app/src/main/java/com/example/ops_android/ui/search/SearchViewModel.kt
new file mode 100644
index 0000000..0321490
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/search/SearchViewModel.kt
@@ -0,0 +1,153 @@
+package com.example.ops_android.ui.search
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.example.ops_android.data.model.Container
+import com.example.ops_android.data.model.Item
+import com.example.ops_android.data.model.PurchaseOrder
+import com.example.ops_android.data.model.WorkOrder
+import com.example.ops_android.data.repository.CustomerRepository
+import com.example.ops_android.data.repository.OrderRepository
+import com.example.ops_android.data.repository.WarehouseRepository
+import com.example.ops_android.data.repository.WorkOrderRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+enum class SearchCategory(val label: String) {
+ ALL("全部"),
+ ORDER("订单"),
+ WORK_ORDER("工单"),
+ ITEM("物品"),
+ CONTAINER("容器")
+}
+
+data class SearchUiState(
+ val query: String = "",
+ val category: SearchCategory = SearchCategory.ALL,
+ val orders: List = emptyList(),
+ val workOrders: List = emptyList(),
+ val items: List- = emptyList(),
+ val containers: List = emptyList(),
+ val isSearching: Boolean = false,
+ val errorMessage: String? = null
+)
+
+class SearchViewModel(
+ private val orderRepository: OrderRepository,
+ private val workOrderRepository: WorkOrderRepository,
+ private val warehouseRepository: WarehouseRepository
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(SearchUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ fun onQueryChange(value: String) {
+ _uiState.value = _uiState.value.copy(query = value)
+ }
+
+ fun selectCategory(category: SearchCategory) {
+ _uiState.value = _uiState.value.copy(category = category)
+ }
+
+ fun onBarcodeScanned(barcode: String) {
+ _uiState.value = _uiState.value.copy(query = barcode)
+ search()
+ }
+
+ fun search() {
+ val query = _uiState.value.query.trim()
+ if (query.isEmpty()) return
+
+ // Parse prefix shortcuts
+ val (actualCategory, actualQuery) = parsePrefix(query)
+ _uiState.value = _uiState.value.copy(category = actualCategory)
+
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isSearching = true, errorMessage = null)
+
+ when (actualCategory) {
+ SearchCategory.ALL -> {
+ launch { orderRepository.getOrders(search = actualQuery, limit = 10).fold(
+ onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(orders = list) },
+ onFailure = { }
+ )}
+ launch { workOrderRepository.list(search = actualQuery, limit = 10).fold(
+ onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list) },
+ onFailure = { }
+ )}
+ launch { warehouseRepository.listItem().fold(
+ onSuccess = { _uiState.value = _uiState.value.copy(items = it.filter { item -> item.name?.contains(actualQuery, true) == true || item.serial_number?.contains(actualQuery, true) == true || item.barcode?.contains(actualQuery, true) == true }.take(10)) },
+ onFailure = { }
+ )}
+ launch { warehouseRepository.listContainer().fold(
+ onSuccess = { _uiState.value = _uiState.value.copy(containers = it.filter { c -> c.name?.contains(actualQuery, true) == true || c.barcode?.contains(actualQuery, true) == true }.take(10)) },
+ onFailure = { }
+ )}
+ }
+ SearchCategory.ORDER -> {
+ orderRepository.getOrders(search = actualQuery, limit = 20).fold(
+ onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(orders = list) },
+ onFailure = { _uiState.value = _uiState.value.copy(errorMessage = it.message) }
+ )
+ }
+ SearchCategory.WORK_ORDER -> {
+ workOrderRepository.list(search = actualQuery, limit = 20).fold(
+ onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list) },
+ onFailure = { _uiState.value = _uiState.value.copy(errorMessage = it.message) }
+ )
+ }
+ SearchCategory.ITEM -> {
+ warehouseRepository.listItem().fold(
+ onSuccess = { items ->
+ _uiState.value = _uiState.value.copy(items = items.filter { it.name?.contains(actualQuery, true) == true || it.serial_number?.contains(actualQuery, true) == true || it.barcode?.contains(actualQuery, true) == true })
+ },
+ onFailure = { _uiState.value = _uiState.value.copy(errorMessage = it.message) }
+ )
+ }
+ SearchCategory.CONTAINER -> {
+ warehouseRepository.listContainer().fold(
+ onSuccess = { containers ->
+ _uiState.value = _uiState.value.copy(containers = containers.filter { it.name?.contains(actualQuery, true) == true || it.barcode?.contains(actualQuery, true) == true })
+ },
+ onFailure = { _uiState.value = _uiState.value.copy(errorMessage = it.message) }
+ )
+ }
+ }
+
+ _uiState.value = _uiState.value.copy(isSearching = false)
+ }
+ }
+
+ fun clearResults() {
+ _uiState.value = SearchUiState(query = _uiState.value.query, category = _uiState.value.category)
+ }
+
+ private fun parsePrefix(query: String): Pair {
+ val prefixes = mapOf(
+ "po:" to SearchCategory.ORDER,
+ "wo:" to SearchCategory.WORK_ORDER,
+ "item:" to SearchCategory.ITEM,
+ "warehouse:" to SearchCategory.CONTAINER
+ )
+ for ((prefix, category) in prefixes) {
+ if (query.startsWith(prefix, ignoreCase = true)) {
+ return category to query.removeRange(0, prefix.length).trim()
+ }
+ }
+ return _uiState.value.category to query
+ }
+
+ class Factory(
+ private val orderRepository: OrderRepository,
+ private val workOrderRepository: WorkOrderRepository,
+ private val warehouseRepository: WarehouseRepository
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return SearchViewModel(orderRepository, workOrderRepository, warehouseRepository) as T
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/example/ops_android/ui/settings/SettingsScreen.kt
new file mode 100644
index 0000000..a0fd6bc
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/settings/SettingsScreen.kt
@@ -0,0 +1,152 @@
+package com.example.ops_android.ui.settings
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.BuildConfig
+import com.example.ops_android.ui.LocalAppContainer
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun SettingsScreen(
+ onBack: () -> Unit,
+ viewModel: SettingsViewModel = viewModel(factory = SettingsViewModel.Factory(LocalAppContainer.current.sessionManager))
+) {
+ val uiState by viewModel.uiState.collectAsState()
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("设置") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回")
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = Color(0xFF667EEA),
+ titleContentColor = Color.White,
+ navigationIconContentColor = Color.White
+ )
+ )
+ }
+ ) { padding ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding)
+ .padding(16.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "服务器配置",
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ OutlinedTextField(
+ value = uiState.apiBaseUrl,
+ onValueChange = viewModel::onUrlChange,
+ label = { Text("API 地址") },
+ placeholder = { Text("例如: http://192.168.1.100:8080/api") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth()
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Button(
+ onClick = { viewModel.saveUrl() },
+ modifier = Modifier.weight(1f)
+ ) {
+ Text("保存")
+ }
+
+ Button(
+ onClick = { viewModel.testConnection() },
+ modifier = Modifier.weight(1f),
+ enabled = !uiState.isTesting,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = if (uiState.testSuccess) Color(0xFF4CAF50)
+ else if (uiState.testResult != null && !uiState.testSuccess) Color(0xFFF44336)
+ else MaterialTheme.colorScheme.secondary
+ )
+ ) {
+ if (uiState.isTesting) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ color = Color.White,
+ strokeWidth = 2.dp
+ )
+ } else {
+ Text("测试连接")
+ }
+ }
+ }
+
+ uiState.message?.let {
+ Text(
+ text = it,
+ color = MaterialTheme.colorScheme.primary,
+ fontSize = 14.sp
+ )
+ }
+
+ uiState.testResult?.let {
+ Card(
+ colors = CardDefaults.cardColors(
+ containerColor = if (uiState.testSuccess) Color(0xFFE8F5E9) else Color(0xFFFFEBEE)
+ )
+ ) {
+ Text(
+ text = it,
+ modifier = Modifier.padding(12.dp),
+ color = if (uiState.testSuccess) Color(0xFF2E7D32) else Color(0xFFC62828),
+ fontSize = 14.sp
+ )
+ }
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ Text(
+ text = "关于",
+ style = MaterialTheme.typography.titleMedium
+ )
+
+ Card {
+ Column(modifier = Modifier.padding(16.dp)) {
+ InfoRow("应用名称", "OPS 运营管理系统")
+ InfoRow("版本", "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
+ InfoRow("作者", BuildConfig.AUTHOR)
+ InfoRow("构建日期", BuildConfig.BUILD_DATE)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun InfoRow(label: String, value: String) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(text = label, color = Color.Gray)
+ Text(text = value)
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/example/ops_android/ui/settings/SettingsViewModel.kt
new file mode 100644
index 0000000..75db92b
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/settings/SettingsViewModel.kt
@@ -0,0 +1,132 @@
+package com.example.ops_android.ui.settings
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.example.ops_android.data.local.SessionManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.net.ConnectException
+import java.net.SocketTimeoutException
+import java.net.UnknownHostException
+import java.util.concurrent.TimeUnit
+import javax.net.ssl.SSLException
+
+data class SettingsUiState(
+ val apiBaseUrl: String = "",
+ val isTesting: Boolean = false,
+ val testResult: String? = null,
+ val testSuccess: Boolean = false,
+ val message: String? = null
+)
+
+class SettingsViewModel(
+ private val sessionManager: SessionManager
+) : ViewModel() {
+
+ private val _uiState = MutableStateFlow(SettingsUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val client = OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(10, TimeUnit.SECONDS)
+ .followRedirects(true)
+ .build()
+
+ init {
+ loadCurrentUrl()
+ }
+
+ private fun loadCurrentUrl() {
+ viewModelScope.launch {
+ val url = sessionManager.getApiBaseUrl()
+ _uiState.value = _uiState.value.copy(apiBaseUrl = url)
+ }
+ }
+
+ fun onUrlChange(value: String) {
+ _uiState.value = _uiState.value.copy(apiBaseUrl = value, message = null)
+ }
+
+ fun saveUrl() {
+ viewModelScope.launch {
+ val url = _uiState.value.apiBaseUrl.trim()
+ if (url.isBlank()) {
+ _uiState.value = _uiState.value.copy(message = "请输入有效的API地址")
+ return@launch
+ }
+ sessionManager.setApiBaseUrl(url)
+ _uiState.value = _uiState.value.copy(message = "API地址已保存")
+ }
+ }
+
+ fun testConnection() {
+ viewModelScope.launch {
+ val url = _uiState.value.apiBaseUrl.trim()
+ if (url.isBlank()) {
+ _uiState.value = _uiState.value.copy(message = "请先输入API地址")
+ return@launch
+ }
+
+ _uiState.value = _uiState.value.copy(isTesting = true, testResult = null, message = null)
+
+ try {
+ val code = withContext(Dispatchers.IO) {
+ val request = Request.Builder().url(url).header("Accept", "application/json").build()
+ val response = client.newCall(request).execute()
+ response.use { it.code }
+ }
+ _uiState.value = _uiState.value.copy(
+ isTesting = false,
+ testResult = if (code in 200..299) "连接成功 ($code)" else "服务器响应异常: HTTP $code",
+ testSuccess = code in 200..299
+ )
+ } catch (e: UnknownHostException) {
+ _uiState.value = _uiState.value.copy(
+ isTesting = false,
+ testResult = "连接失败: 无法解析服务器地址",
+ testSuccess = false
+ )
+ } catch (e: SocketTimeoutException) {
+ _uiState.value = _uiState.value.copy(
+ isTesting = false,
+ testResult = "连接超时,请检查网络和服务器地址",
+ testSuccess = false
+ )
+ } catch (e: ConnectException) {
+ _uiState.value = _uiState.value.copy(
+ isTesting = false,
+ testResult = "连接被拒绝: ${e.message ?: "服务器不可达"}",
+ testSuccess = false
+ )
+ } catch (e: SSLException) {
+ _uiState.value = _uiState.value.copy(
+ isTesting = false,
+ testResult = "SSL证书错误,HTTP地址请使用http://",
+ testSuccess = false
+ )
+ } catch (e: Exception) {
+ _uiState.value = _uiState.value.copy(
+ isTesting = false,
+ testResult = "连接失败: ${e.javaClass.simpleName} - ${e.message ?: "未知错误"}",
+ testSuccess = false
+ )
+ }
+ }
+ }
+
+ class Factory(
+ private val sessionManager: SessionManager
+ ) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T {
+ return SettingsViewModel(sessionManager) as T
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/ItemDetailScreen.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemDetailScreen.kt
new file mode 100644
index 0000000..e290404
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemDetailScreen.kt
@@ -0,0 +1,70 @@
+package com.example.ops_android.ui.warehouse
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.data.model.Item
+import com.example.ops_android.data.repository.WarehouseRepository
+import com.example.ops_android.ui.LocalAppContainer
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class ItemDetailUiState(val item: Item? = null, val isLoading: Boolean = false, val errorMessage: String? = null)
+
+class ItemDetailViewModel(repo: WarehouseRepository, private val id: Int) : ViewModel() {
+ private val _s = MutableStateFlow(ItemDetailUiState()); val s: StateFlow = _s.asStateFlow()
+ private val repository = repo
+ init { load() }
+ private fun load() { viewModelScope.launch { _s.value = _s.value.copy(isLoading = true); repository.getItem(id).fold(onSuccess = { _s.value = _s.value.copy(item = it, isLoading = false) }, onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }) } }
+ class Factory(private val repo: WarehouseRepository, private val id: Int) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = ItemDetailViewModel(repo, id) as T }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ItemDetailScreen(id: Int, onBack: () -> Unit) {
+ val vm: ItemDetailViewModel = viewModel(key = "item_$id", factory = ItemDetailViewModel.Factory(LocalAppContainer.current.warehouseRepository, id))
+ val s by vm.s.collectAsState(); val item = s.item
+
+ Scaffold(topBar = { TopAppBar(title = { Text("物品详情") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }) { padding ->
+ if (s.isLoading) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }; return@Scaffold }
+ if (item == null) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text(s.errorMessage ?: "加载失败") }; return@Scaffold }
+
+ LazyColumn(Modifier.fillMaxSize().padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ item { Text("基本信息", fontWeight = FontWeight.Bold, fontSize = 18.sp); Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { DetailRow("名称", item.name ?: "-"); DetailRow("编号", item.serial_number ?: "-"); DetailRow("规格", item.spec ?: "-"); DetailRow("条码", item.barcode ?: "-"); DetailRow("备注", item.remark ?: "-"); DetailRow("创建时间", item.created_at ?: "-") } } }
+
+ item.move_history?.let { history ->
+ if (history.isNotEmpty()) {
+ item { Text("移动记录", fontWeight = FontWeight.Bold, fontSize = 16.sp) }
+ items(history) { h ->
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text("${h.from_container ?: "-"} → ${h.to_container ?: "-"}", fontWeight = FontWeight.Medium); Text(h.moved_at ?: "", fontSize = 12.sp, color = Color.Gray) }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DetailRow(label: String, value: String) {
+ Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(label, color = Color.Gray, modifier = Modifier.width(80.dp)); Text(value) }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/ItemFormScreen.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemFormScreen.kt
new file mode 100644
index 0000000..bbc8f5a
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/warehouse/ItemFormScreen.kt
@@ -0,0 +1,65 @@
+package com.example.ops_android.ui.warehouse
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.data.repository.WarehouseRepository
+import com.example.ops_android.ui.LocalAppContainer
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class ItemFormUiState(
+ val name: String = "", val serialNumber: String = "", val spec: String = "", val remark: String = "",
+ val isSubmitting: Boolean = false, val errorMessage: String? = null, val success: Boolean = false
+)
+
+class ItemFormViewModel(private val repo: WarehouseRepository, private val containerId: Int, private val editId: Int? = null) : ViewModel() {
+ private val _s = MutableStateFlow(ItemFormUiState()); val s: StateFlow = _s.asStateFlow()
+ fun onName(v: String) { _s.value = _s.value.copy(name = v) }
+ fun onSerial(v: String) { _s.value = _s.value.copy(serialNumber = v) }
+ fun onSpec(v: String) { _s.value = _s.value.copy(spec = v) }
+ fun onRemark(v: String) { _s.value = _s.value.copy(remark = v) }
+ fun submit() {
+ if (_s.value.name.isBlank()) { _s.value = _s.value.copy(errorMessage = "名称不能为空"); return }
+ val data = mapOf("name" to _s.value.name, "serial_number" to _s.value.serialNumber, "spec" to _s.value.spec, "remark" to _s.value.remark, "container_id" to containerId)
+ viewModelScope.launch {
+ _s.value = _s.value.copy(isSubmitting = true)
+ val r = if (editId != null) repo.updateItem(data + ("id" to editId)) else repo.addItem(data)
+ r.fold(onSuccess = { _s.value = _s.value.copy(isSubmitting = false, success = true) }, onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) })
+ }
+ }
+ class Factory(private val repo: WarehouseRepository, private val containerId: Int, private val editId: Int? = null) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = ItemFormViewModel(repo, containerId, editId) as T }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ItemFormScreen(containerId: Int = 0, editId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) {
+ val vm: ItemFormViewModel = viewModel(factory = ItemFormViewModel.Factory(LocalAppContainer.current.warehouseRepository, containerId, editId))
+ val s by vm.s.collectAsState()
+ LaunchedEffect(s.success) { if (s.success) onSaved() }
+
+ Scaffold(topBar = { TopAppBar(title = { Text(if (editId != null) "编辑物品" else "添加物品") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }) { padding ->
+ Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ OutlinedTextField(s.name, vm::onName, label = { Text("名称 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(s.serialNumber, vm::onSerial, label = { Text("编号") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(s.spec, vm::onSpec, label = { Text("规格") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(s.remark, vm::onRemark, label = { Text("备注") }, modifier = Modifier.fillMaxWidth(), minLines = 2)
+ s.errorMessage?.let { Text(it, color = Color.Red, fontSize = 14.sp) }
+ Button(onClick = { vm.submit() }, Modifier.fillMaxWidth().height(48.dp), enabled = !s.isSubmitting) { Text("提交") }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseScreen.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseScreen.kt
new file mode 100644
index 0000000..d4072e5
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseScreen.kt
@@ -0,0 +1,102 @@
+package com.example.ops_android.ui.warehouse
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.ChevronRight
+import androidx.compose.material.icons.filled.Folder
+import androidx.compose.material.icons.filled.Inventory
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.data.model.Container
+import com.example.ops_android.data.model.Item
+import com.example.ops_android.ui.LocalAppContainer
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WarehouseScreen(
+ onItemClick: (Int) -> Unit,
+ viewModel: WarehouseViewModel = viewModel(factory = WarehouseViewModel.Factory(LocalAppContainer.current.warehouseRepository))
+) {
+ val s by viewModel.s.collectAsState()
+ var showAddDialog by remember { mutableStateOf(false) }
+ var newContainerName by remember { mutableStateOf("") }
+ var tabIndex by remember { mutableStateOf(0) }
+
+ Scaffold(
+ topBar = { TopAppBar(title = { Text("仓库") }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) },
+ floatingActionButton = { FloatingActionButton(onClick = { showAddDialog = true }, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "新建容器", tint = Color.White) } }
+ ) { padding ->
+ Column(Modifier.padding(padding)) {
+ // Breadcrumb
+ ScrollableTabRow(selectedTabIndex = s.breadcrumb.size - 1) {
+ s.breadcrumb.forEachIndexed { idx, (id, name) ->
+ Tab(selected = idx == s.breadcrumb.size - 1, onClick = { viewModel.navigateToBreadcrumb(idx) }, text = { Text(name, fontSize = 12.sp) })
+ }
+ }
+
+ // Tabs: All / Items Only
+ Row(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ FilterChip(selected = !s.showItemsOnly, onClick = { viewModel.toggleItemsOnly() }, label = { Text("全部") })
+ FilterChip(selected = s.showItemsOnly, onClick = { viewModel.toggleItemsOnly() }, label = { Text("仅物品") })
+ }
+
+ if (s.isLoading) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
+ else LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ if (!s.showItemsOnly) {
+ items(s.containers) { container ->
+ ContainerItem(container) { viewModel.navigateTo(container.id, container.name ?: "未命名") }
+ }
+ }
+ items(s.items) { item ->
+ ItemRow(item) { onItemClick(item.id) }
+ }
+ }
+ }
+ }
+
+ if (showAddDialog) AlertDialog(
+ onDismissRequest = { showAddDialog = false },
+ title = { Text("新建容器") },
+ text = { OutlinedTextField(newContainerName, { newContainerName = it }, label = { Text("容器名称") }, singleLine = true) },
+ confirmButton = { Button(onClick = { if (newContainerName.isNotBlank()) { viewModel.addContainer(newContainerName); showAddDialog = false; newContainerName = "" } }) { Text("创建") } },
+ dismissButton = { TextButton(onClick = { showAddDialog = false }) { Text("取消") } }
+ )
+}
+
+@Composable
+private fun ContainerItem(container: Container, onClick: () -> Unit) {
+ Card(Modifier.fillMaxWidth().clickable { onClick() }) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Folder, null, tint = Color(0xFFFF9800))
+ Spacer(Modifier.width(12.dp))
+ Text(container.name ?: "未命名", modifier = Modifier.weight(1f), fontWeight = FontWeight.Medium)
+ Icon(Icons.Default.ChevronRight, null, tint = Color.Gray)
+ }
+ }
+}
+
+@Composable
+private fun ItemRow(item: Item, onClick: () -> Unit) {
+ Card(Modifier.fillMaxWidth().clickable { onClick() }) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Inventory, null, tint = Color(0xFF2196F3))
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(item.name ?: "未命名", fontWeight = FontWeight.Medium)
+ item.serial_number?.let { Text(it, fontSize = 12.sp, color = Color.Gray) }
+ }
+ Icon(Icons.Default.ChevronRight, null, tint = Color.Gray)
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseViewModel.kt b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseViewModel.kt
new file mode 100644
index 0000000..a6c65cc
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/warehouse/WarehouseViewModel.kt
@@ -0,0 +1,74 @@
+package com.example.ops_android.ui.warehouse
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.example.ops_android.data.model.Container
+import com.example.ops_android.data.model.Item
+import com.example.ops_android.data.repository.WarehouseRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class WarehouseUiState(
+ val containers: List = emptyList(),
+ val items: List
- = emptyList(),
+ val breadcrumb: List> = listOf(0 to "根目录"),
+ val showItemsOnly: Boolean = false,
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null
+)
+
+class WarehouseViewModel(
+ private val repo: WarehouseRepository
+) : ViewModel() {
+ private val _s = MutableStateFlow(WarehouseUiState()); val s: StateFlow = _s.asStateFlow()
+
+ init { loadContainers(0) }
+
+ fun loadContainers(parentId: Int) {
+ viewModelScope.launch {
+ _s.value = _s.value.copy(isLoading = true)
+ repo.listContainer(parentId).fold(
+ onSuccess = { _s.value = _s.value.copy(containers = it, isLoading = false) },
+ onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }
+ )
+ repo.listItem(parentId).fold(
+ onSuccess = { _s.value = _s.value.copy(items = it) },
+ onFailure = { }
+ )
+ }
+ }
+
+ fun navigateTo(containerId: Int, containerName: String) {
+ val breadcrumb = _s.value.breadcrumb.toMutableList()
+ breadcrumb.add(containerId to containerName)
+ _s.value = _s.value.copy(breadcrumb = breadcrumb)
+ loadContainers(containerId)
+ }
+
+ fun navigateToBreadcrumb(index: Int) {
+ val entry = _s.value.breadcrumb[index]
+ _s.value = _s.value.copy(breadcrumb = _s.value.breadcrumb.take(index + 1))
+ loadContainers(entry.first)
+ }
+
+ fun toggleItemsOnly() { _s.value = _s.value.copy(showItemsOnly = !_s.value.showItemsOnly) }
+
+ fun refresh() { loadContainers(_s.value.breadcrumb.last().first) }
+
+ fun addContainer(name: String) {
+ val parentId = _s.value.breadcrumb.last().first
+ viewModelScope.launch {
+ repo.addContainer(mapOf("name" to name, "parent_id" to parentId)).fold(
+ onSuccess = { refresh() },
+ onFailure = { _s.value = _s.value.copy(errorMessage = it.message) }
+ )
+ }
+ }
+
+ class Factory(private val repo: WarehouseRepository) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = WarehouseViewModel(repo) as T
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderDetailScreen.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderDetailScreen.kt
new file mode 100644
index 0000000..5724b5e
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderDetailScreen.kt
@@ -0,0 +1,114 @@
+package com.example.ops_android.ui.workorder
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Print
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.data.model.Commit
+import com.example.ops_android.data.model.WorkOrder
+import com.example.ops_android.data.repository.WorkOrderRepository
+import com.example.ops_android.ui.LocalAppContainer
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class WorkOrderDetailUiState(
+ val workOrder: WorkOrder? = null, val isLoading: Boolean = false, val errorMessage: String? = null,
+ val showCommitDialog: Boolean = false, val commitStatus: String = "", val commitComment: String = "", val isSubmitting: Boolean = false
+)
+
+class WorkOrderDetailViewModel(repo: WorkOrderRepository, private val id: Int) : ViewModel() {
+ private val _s = MutableStateFlow(WorkOrderDetailUiState()); val s: StateFlow = _s.asStateFlow()
+ private val repository = repo
+
+ init { load() }
+ fun load() { viewModelScope.launch { _s.value = _s.value.copy(isLoading = true); repository.get(id).fold(onSuccess = { _s.value = _s.value.copy(workOrder = it, isLoading = false) }, onFailure = { _s.value = _s.value.copy(isLoading = false, errorMessage = it.message) }) } }
+ fun showCommitDialog(status: String) { _s.value = _s.value.copy(showCommitDialog = true, commitStatus = status, commitComment = "") }
+ fun dismissCommitDialog() { _s.value = _s.value.copy(showCommitDialog = false) }
+ fun onCommentChange(v: String) { _s.value = _s.value.copy(commitComment = v) }
+ fun submitCommit() { viewModelScope.launch {
+ val st = _s.value; _s.value = st.copy(isSubmitting = true)
+ repository.commit(id, st.commitStatus.ifBlank { null }, st.commitComment.ifBlank { null }, null)
+ .fold(onSuccess = { _s.value = _s.value.copy(showCommitDialog = false, isSubmitting = false); load() }, onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) })
+ } }
+
+ class Factory(private val repo: WorkOrderRepository, private val id: Int) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = WorkOrderDetailViewModel(repo, id) as T
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WorkOrderDetailScreen(id: Int, onBack: () -> Unit) {
+ val vm: WorkOrderDetailViewModel = viewModel(key = "wo_$id", factory = WorkOrderDetailViewModel.Factory(LocalAppContainer.current.workOrderRepository, id))
+ val s by vm.s.collectAsState(); val wo = s.workOrder
+ val sl = mapOf("pending" to "待处理", "checked" to "已检查", "parts_ordered" to "待配件", "repaired" to "已修复", "returned" to "已返还", "unrepairable" to "不可修")
+
+ Scaffold(topBar = {
+ TopAppBar(title = { Text("工单详情") },
+ navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } },
+ actions = {
+ val scope = rememberCoroutineScope()
+ wo?.let { wo_ ->
+ val printerManager = com.example.ops_android.ui.LocalAppContainer.current.printerManager
+ if (printerManager != null) {
+ IconButton(onClick = {
+ scope.launch { com.example.ops_android.printer.PrintHelper.printWorkOrderLabel(printerManager, wo_) }
+ }) { Icon(Icons.Default.Print, "打印", tint = Color.White) }
+ }
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White))
+ }) { padding ->
+ if (s.isLoading) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { CircularProgressIndicator() }; return@Scaffold }
+ if (wo == null) { Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { Text(s.errorMessage ?: "加载失败") }; return@Scaffold }
+
+ LazyColumn(Modifier.fillMaxSize().padding(padding).padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ item { Text("基本信息", fontWeight = FontWeight.Bold, fontSize = 18.sp); Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(16.dp)) { DetailRow("标题", wo.title ?: "-"); DetailRow("状态", sl[wo.status] ?: wo.status ?: "-"); DetailRow("描述", wo.description ?: "-"); DetailRow("创建时间", wo.created_at ?: "-") } } }
+
+ wo.commits?.let { commits ->
+ if (commits.isNotEmpty()) {
+ item { Text("提交记录", fontWeight = FontWeight.Bold, fontSize = 16.sp) }
+ items(commits) { c: Commit ->
+ Card(Modifier.fillMaxWidth()) {
+ Column(Modifier.padding(12.dp)) {
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(sl[c.status] ?: c.status ?: "", fontWeight = FontWeight.Medium, color = Color(0xFF667EEA)); Text(c.created_at ?: "", fontSize = 12.sp, color = Color.Gray) }
+ c.comment?.takeIf { it.isNotBlank() }?.let { Spacer(Modifier.height(4.dp)); Text(it, fontSize = 14.sp) }
+ }
+ }
+ }
+ }
+ }
+
+ item { Spacer(Modifier.height(8.dp)); Text("流转状态", fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ Row(Modifier.fillMaxWidth().wrapContentHeight(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ listOf("checked", "parts_ordered", "repaired", "returned", "unrepairable").forEach { st ->
+ if (st != wo.status) FilterChip(selected = false, onClick = { vm.showCommitDialog(st) }, label = { Text(sl[st] ?: st, fontSize = 12.sp) })
+ }
+ }
+ }
+ }
+ }
+
+ if (s.showCommitDialog) AlertDialog(onDismissRequest = { vm.dismissCommitDialog() }, title = { Text("流转为「${sl[s.commitStatus] ?: s.commitStatus}」") }, text = { Column { Text("备注(可选):"); Spacer(Modifier.height(8.dp)); OutlinedTextField(value = s.commitComment, onValueChange = vm::onCommentChange, Modifier.fillMaxWidth(), minLines = 2, placeholder = { Text("备注") }) } }, confirmButton = { Button(onClick = { vm.submitCommit() }, enabled = !s.isSubmitting) { Text("确认") } }, dismissButton = { TextButton(onClick = { vm.dismissCommitDialog() }) { Text("取消") } })
+}
+
+@Composable
+private fun DetailRow(label: String, value: String) {
+ Row(Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text(label, color = Color.Gray, modifier = Modifier.width(80.dp)); Text(value) }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormScreen.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormScreen.kt
new file mode 100644
index 0000000..af2a009
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderFormScreen.kt
@@ -0,0 +1,64 @@
+package com.example.ops_android.ui.workorder
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.data.repository.WorkOrderRepository
+import com.example.ops_android.ui.LocalAppContainer
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+data class WorkOrderFormUiState(
+ val title: String = "", val description: String = "", val isSubmitting: Boolean = false, val errorMessage: String? = null, val success: Boolean = false
+)
+
+class WorkOrderFormViewModel(private val repo: WorkOrderRepository, private val editId: Int? = null) : ViewModel() {
+ private val _s = MutableStateFlow(WorkOrderFormUiState()); val s: StateFlow = _s.asStateFlow()
+ fun onTitle(v: String) { _s.value = _s.value.copy(title = v) }
+ fun onDesc(v: String) { _s.value = _s.value.copy(description = v) }
+ fun submit() {
+ if (_s.value.title.isBlank()) { _s.value = _s.value.copy(errorMessage = "标题不能为空"); return }
+ val data = mapOf("title" to _s.value.title, "description" to _s.value.description)
+ viewModelScope.launch {
+ _s.value = _s.value.copy(isSubmitting = true, errorMessage = null)
+ val r = if (editId != null) repo.update(data + ("id" to editId)) else repo.add(data)
+ r.fold(onSuccess = { _s.value = _s.value.copy(isSubmitting = false, success = true) }, onFailure = { _s.value = _s.value.copy(isSubmitting = false, errorMessage = it.message) })
+ }
+ }
+ class Factory(private val repo: WorkOrderRepository, private val editId: Int? = null) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST") override fun create(c: Class): T = WorkOrderFormViewModel(repo, editId) as T
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WorkOrderFormScreen(editId: Int? = null, onBack: () -> Unit, onSaved: () -> Unit) {
+ val vm: WorkOrderFormViewModel = viewModel(factory = WorkOrderFormViewModel.Factory(LocalAppContainer.current.workOrderRepository, editId))
+ val s by vm.s.collectAsState()
+ LaunchedEffect(s.success) { if (s.success) onSaved() }
+
+ Scaffold(topBar = { TopAppBar(title = { Text(if (editId != null) "编辑工单" else "新建工单") }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, null, tint = Color.White) } }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White)) }) { padding ->
+ Column(Modifier.fillMaxSize().padding(padding).padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ OutlinedTextField(s.title, vm::onTitle, label = { Text("标题 *") }, modifier = Modifier.fillMaxWidth(), singleLine = true)
+ OutlinedTextField(s.description, vm::onDesc, label = { Text("描述") }, modifier = Modifier.fillMaxWidth(), minLines = 3)
+ s.errorMessage?.let { Text(it, color = Color.Red, fontSize = 14.sp) }
+ Button(onClick = { vm.submit() }, Modifier.fillMaxWidth().height(48.dp), enabled = !s.isSubmitting) {
+ if (s.isSubmitting) CircularProgressIndicator(Modifier.size(20.dp), color = Color.White, strokeWidth = 2.dp) else Text("提交")
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListScreen.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListScreen.kt
new file mode 100644
index 0000000..be741e5
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListScreen.kt
@@ -0,0 +1,66 @@
+package com.example.ops_android.ui.workorder
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.example.ops_android.data.model.WorkOrder
+import com.example.ops_android.ui.LocalAppContainer
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun WorkOrderListScreen(
+ onWorkOrderClick: (Int) -> Unit,
+ onAddClick: () -> Unit,
+ viewModel: WorkOrderListViewModel = viewModel(factory = WorkOrderListViewModel.Factory(LocalAppContainer.current.workOrderRepository))
+) {
+ val uiState by viewModel.uiState.collectAsState()
+ val statusColors = mapOf("pending" to Color(0xFFFF9800), "checked" to Color(0xFF2196F3), "parts_ordered" to Color(0xFF9C27B0), "repaired" to Color(0xFF4CAF50), "returned" to Color(0xFF4CAF50), "unrepairable" to Color(0xFFF44336))
+ val statusLabels = mapOf("pending" to "待处理", "checked" to "已检查", "parts_ordered" to "待配件", "repaired" to "已修复", "returned" to "已返还", "unrepairable" to "不可修")
+
+ Scaffold(
+ topBar = {
+ TopAppBar(title = { Text("工单") }, actions = { IconButton(onClick = onAddClick) { Icon(Icons.Default.Add, "新建", tint = Color.White) } },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color(0xFF667EEA), titleContentColor = Color.White))
+ },
+ floatingActionButton = { FloatingActionButton(onClick = onAddClick, containerColor = Color(0xFF667EEA)) { Icon(Icons.Default.Add, "新建", tint = Color.White) } }
+ ) { padding ->
+ Column(Modifier.padding(padding)) {
+ ScrollableTabRow(selectedTabIndex = WO_STATUSES.indexOfFirst { it.first == uiState.selectedStatus }.coerceAtLeast(0), edgePadding = 8.dp) {
+ WO_STATUSES.forEachIndexed { i, (s, l) ->
+ Tab(selected = uiState.selectedStatus == s, onClick = { viewModel.selectStatus(s) }, text = { Text(l, fontSize = 12.sp, fontWeight = if (uiState.selectedStatus == s) FontWeight.Bold else FontWeight.Normal) })
+ }
+ }
+ if (uiState.isLoading) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() }
+ else LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ items(uiState.workOrders, key = { it.id }) { wo ->
+ Card(Modifier.fillMaxWidth().clickable { onWorkOrderClick(wo.id) }) {
+ Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
+ Column(Modifier.weight(1f)) {
+ Text(wo.title ?: "无标题", fontWeight = FontWeight.Medium)
+ Spacer(Modifier.height(4.dp))
+ Text(wo.description ?: "", fontSize = 12.sp, color = Color.Gray, maxLines = 1)
+ }
+ Surface(shape = MaterialTheme.shapes.small, color = statusColors[wo.status]?.copy(alpha = 0.15f) ?: Color.Gray) {
+ Text(statusLabels[wo.status] ?: wo.status ?: "", modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), fontSize = 11.sp, color = statusColors[wo.status] ?: Color.Gray)
+ }
+ }
+ }
+ }
+ if (uiState.isLoadingMore) item { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator(Modifier.size(24.dp)) } }
+ item { LaunchedEffect(Unit) { viewModel.loadMore() } }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListViewModel.kt b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListViewModel.kt
new file mode 100644
index 0000000..0cdd12a
--- /dev/null
+++ b/app/src/main/java/com/example/ops_android/ui/workorder/WorkOrderListViewModel.kt
@@ -0,0 +1,83 @@
+package com.example.ops_android.ui.workorder
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.viewModelScope
+import com.example.ops_android.data.model.WorkOrder
+import com.example.ops_android.data.repository.WorkOrderRepository
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.launch
+
+val WO_STATUSES = listOf(
+ "" to "全部",
+ "pending" to "待处理",
+ "checked" to "已检查",
+ "parts_ordered" to "待配件",
+ "repaired" to "已修复",
+ "returned" to "已返还",
+ "unrepairable" to "不可修"
+)
+
+data class WorkOrderListUiState(
+ val workOrders: List = emptyList(),
+ val selectedStatus: String = "",
+ val isLoading: Boolean = false,
+ val isLoadingMore: Boolean = false,
+ val hasMore: Boolean = true,
+ val errorMessage: String? = null
+)
+
+class WorkOrderListViewModel(
+ private val repository: WorkOrderRepository
+) : ViewModel() {
+ private val _uiState = MutableStateFlow(WorkOrderListUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+ private val pageSize = 20
+ private var offset = 0
+
+ init { load() }
+
+ fun selectStatus(s: String) { _uiState.value = _uiState.value.copy(selectedStatus = s); refresh() }
+
+ fun refresh() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true, workOrders = emptyList())
+ offset = 0
+ val r = repository.list(status = _uiState.value.selectedStatus.ifEmpty { null })
+ r.fold(
+ onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list, isLoading = false, hasMore = list.size >= pageSize) },
+ onFailure = { e -> _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = e.message) }
+ )
+ }
+ }
+
+ fun loadMore() {
+ if (_uiState.value.isLoadingMore || !_uiState.value.hasMore) return
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoadingMore = true)
+ offset += pageSize
+ val r = repository.list(status = _uiState.value.selectedStatus.ifEmpty { null }, offset = offset, limit = pageSize)
+ r.fold(
+ onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = _uiState.value.workOrders + list, isLoadingMore = false, hasMore = list.size >= pageSize) },
+ onFailure = { _uiState.value = _uiState.value.copy(isLoadingMore = false) }
+ )
+ }
+ }
+
+ private fun load() {
+ viewModelScope.launch {
+ _uiState.value = _uiState.value.copy(isLoading = true)
+ repository.list().fold(
+ onSuccess = { (list, _) -> _uiState.value = _uiState.value.copy(workOrders = list, isLoading = false, hasMore = list.size >= pageSize) },
+ onFailure = { e -> _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = e.message) }
+ )
+ }
+ }
+
+ class Factory(private val repo: WorkOrderRepository) : ViewModelProvider.Factory {
+ @Suppress("UNCHECKED_CAST")
+ override fun create(modelClass: Class): T = WorkOrderListViewModel(repo) as T
+ }
+}
diff --git a/app/version.properties b/app/version.properties
index d3beaed..57f264d 100644
--- a/app/version.properties
+++ b/app/version.properties
@@ -1,3 +1,3 @@
-#Wed Jun 24 20:47:38 CST 2026
-VERSION_CODE=23
+#Wed Jun 24 22:02:35 CST 2026
+VERSION_CODE=45
VERSION_NAME=1.2
diff --git a/build.gradle.kts b/build.gradle.kts
index 18318be..020183f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,5 +1,4 @@
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
-}
\ No newline at end of file
+}
diff --git a/gradle.properties b/gradle.properties
index 99fc155..f6e13a0 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -16,4 +16,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# has changed. Additionally, Gradle applies performance optimizations to task execution.
org.gradle.configuration-cache=true
# Kotlin code style for this project: "official" or "obsolete":
-kotlin.code.style=official
\ No newline at end of file
+kotlin.code.style=official
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 394ece7..10ace24 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -8,6 +8,15 @@ lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
kotlin = "2.2.10"
composeBom = "2026.02.01"
+retrofit = "2.9.0"
+okhttp = "4.12.0"
+gson = "2.10.1"
+navigationCompose = "2.7.7"
+coil = "2.5.0"
+datastore = "1.0.0"
+camerax = "1.3.0"
+mlkitBarcode = "17.2.0"
+coroutines = "1.7.3"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -24,8 +33,25 @@ androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "u
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
+
+retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
+retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
+gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
+
+coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
+
+camerax-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
+camerax-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
+camerax-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
+mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" }
+
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
-