移植完成,网络通信有问题

This commit is contained in:
2026-06-24 22:05:19 +08:00
parent 5a35ef059c
commit 28a5d0f6fb
70 changed files with 5353 additions and 429 deletions
+19 -4
View File
@@ -8,7 +8,6 @@ plugins {
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
} }
// 自动版本号管理
val versionPropsFile = file("version.properties") val versionPropsFile = file("version.properties")
fun getVersionCode(): Int { fun getVersionCode(): Int {
val props = Properties() val props = Properties()
@@ -45,7 +44,6 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
// 作者信息
buildConfigField("String", "AUTHOR", "\"无闻风\"") buildConfigField("String", "AUTHOR", "\"无闻风\"")
buildConfigField("String", "BUILD_DATE", "\"${SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(Date())}\"") 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(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3) implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.ui) implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics) implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview) implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.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")) implementation(files("libs/lcprintsdk-release.aar"))
testImplementation(libs.junit) testImplementation(libs.junit)
@@ -106,4 +121,4 @@ dependencies {
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest) debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.tooling)
} }
+16 -3
View File
@@ -2,7 +2,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FLASHLIGHT" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<application <application
android:name=".OpsApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@@ -10,7 +22,9 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Ops_android"> android:theme="@style/Theme.Ops_android"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -19,10 +33,9 @@
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
</manifest> </manifest>
@@ -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<UserApi>() }
val usersApi by lazy { apiServiceFactory.create<UsersApi>() }
val purchaseApi by lazy { apiServiceFactory.create<PurchaseApi>() }
val workOrderApi by lazy { apiServiceFactory.create<WorkOrderApi>() }
val warehouseApi by lazy { apiServiceFactory.create<WarehouseApi>() }
val customerApi by lazy { apiServiceFactory.create<CustomerApi>() }
val scheduleApi by lazy { apiServiceFactory.create<ScheduleApi>() }
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() }
}
}
@@ -4,32 +4,33 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.Surface
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.ops_android.printer.PrinterManager 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 com.example.ops_android.ui.theme.Ops_androidTheme
import kotlinx.coroutines.runBlocking
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var printerManager: PrinterManager
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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() enableEdgeToEdge()
setContent { setContent {
Ops_androidTheme { Ops_androidTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> CompositionLocalProvider(LocalAppContainer provides appContainer) {
PrinterTestScreen( Surface(modifier = Modifier.fillMaxSize()) {
printerManager = printerManager, OPSNavGraph(isLoggedIn = isLoggedIn)
modifier = Modifier.padding(innerPadding) }
)
} }
} }
} }
@@ -37,412 +38,11 @@ class MainActivity : ComponentActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
printerManager.bindService() (application as OpsApplication).appContainer.printerManager?.bindService()
} }
override fun onDestroy() { override fun onDestroy() {
super.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}") }
)
}
@@ -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)
}
}
@@ -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<Int, UserDetail>()
private val inflightRequests = ConcurrentHashMap<Int, CompletableDeferred<UserDetail?>>()
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<UserDetail?>()
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()
}
}
@@ -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<Preferences> 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<String> = context.dataStore.data.map { prefs -> prefs[KEY_API_BASE_URL] ?: "" }
val cookieValue: Flow<String> = 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()
}
@@ -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
)
@@ -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
)
@@ -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
)
@@ -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<String>? = null,
val move_history: List<MoveHistory>? = 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
)
@@ -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
)
@@ -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<String>? = null,
val costs: List<Cost>? = null,
val photos: List<String>? = null,
val work_order_ids: List<Int>? = null,
val status_logs: List<StatusLog>? = 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<String>? = null,
val created_at: String? = null,
val user_id: Int? = null
)
@@ -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
)
@@ -0,0 +1,6 @@
package com.example.ops_android.data.model
data class Stats(
val total_containers: Int = 0,
val total_items: Int = 0
)
@@ -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
)
@@ -0,0 +1,6 @@
package com.example.ops_android.data.model
data class UserInfo(
val ID: Int? = null,
val Name: String? = null
)
@@ -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<Int>? = null,
val purchase_order_ids: List<Int>? = null,
val customer_id: Int? = null,
val commits: List<Commit>? = 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<String>? = null,
val created_at: String? = null,
val user_id: Int? = null
)
@@ -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
)
@@ -0,0 +1,24 @@
package com.example.ops_android.data.remote
import com.google.gson.annotations.SerializedName
data class ApiResponse<T>(
@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?
)
@@ -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 <T> create(serviceClass: Class<T>): T {
return Retrofit.Builder()
.baseUrl("http://localhost/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
.create(serviceClass)
}
inline fun <reified T> 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()
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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<String, Any> ?: 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()
)
}
}
@@ -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<Customer>?
)
data class CustomerGetRequest(
val id: Int
)
data class CustomerResponse(
val customer: Customer?
)
interface CustomerApi {
@POST("/customer/list")
suspend fun list(@Body request: Map<String, Any> = emptyMap()): ApiResponse<CustomerListResponse>
@POST("/customer/get")
suspend fun get(@Body request: CustomerGetRequest): ApiResponse<CustomerResponse>
@POST("/customer/add")
suspend fun add(@Body request: Map<String, Any>): ApiResponse<Any>
@POST("/customer/update")
suspend fun update(@Body request: Map<String, Any>): ApiResponse<Any>
@POST("/customer/delete")
suspend fun delete(@Body request: Map<String, Int>): ApiResponse<Any>
}
@@ -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<PurchaseOrder>?,
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<String>? = null
)
data class OrderResponse(
val order: PurchaseOrder?
)
interface PurchaseApi {
@POST("/purchase/getorders")
suspend fun getOrders(@Body request: GetOrdersRequest): ApiResponse<OrdersListResponse>
@POST("/purchase/getorder")
suspend fun getOrder(@Body request: GetOrderRequest): ApiResponse<OrderResponse>
@POST("/purchase/getordercount")
suspend fun getOrderCount(@Body request: Map<String, Any> = emptyMap()): ApiResponse<OrderCounts>
@POST("/purchase/updatestatus")
suspend fun updateOrderStatus(@Body request: UpdateOrderStatusRequest): ApiResponse<Any>
@POST("/purchase/addorder")
suspend fun addOrder(@Body request: Map<String, Any>): ApiResponse<Any>
@POST("/purchase/updateorder")
suspend fun updateOrder(@Body request: Map<String, Any>): ApiResponse<Any>
}
@@ -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<ScheduleEvent>?
)
interface ScheduleApi {
@POST("/schedule/getevents")
suspend fun getEvents(@Body request: GetEventsRequest): ApiResponse<EventsResponse>
@POST("/schedule/addevent")
suspend fun addEvent(@Body request: Map<String, Any>): ApiResponse<Any>
@POST("/schedule/editevent")
suspend fun editEvent(@Body request: Map<String, Any>): ApiResponse<Any>
@POST("/schedule/deleevent")
suspend fun deleteEvent(@Body request: Map<String, Int>): ApiResponse<Any>
}
@@ -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<LoginResponse>
@POST("/users/register")
suspend fun register(@Body request: RegisterRequest): ApiResponse<Any>
@POST("/users/getinfo")
suspend fun getUserInfo(@Body request: Map<String, Any> = emptyMap()): ApiResponse<UserInfoResponse>
@POST("/users/changePassword")
suspend fun changePassword(@Body request: ChangePasswordRequest): ApiResponse<Any>
@POST("/users/changeEmail")
suspend fun changeEmail(@Body request: ChangeEmailRequest): ApiResponse<Any>
@POST("/users/updateInfo")
suspend fun updateInfo(@Body request: UpdateInfoRequest): ApiResponse<Any>
}
@@ -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<UserInfoByIdResponse>
}
@@ -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<Container>?
)
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<Item>?
)
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<ListContainerResponse>
@POST("/warehouse/get_container")
suspend fun getContainer(@Body request: GetContainerRequest): ApiResponse<ContainerResponse>
@POST("/warehouse/add_container")
suspend fun addContainer(@Body request: Map<String, Any>): ApiResponse<Any>
@POST("/warehouse/update_container")
suspend fun updateContainer(@Body request: Map<String, Any>): ApiResponse<Any>
@POST("/warehouse/delete_container")
suspend fun deleteContainer(@Body request: Map<String, Int>): ApiResponse<Any>
@POST("/warehouse/list_item")
suspend fun listItem(@Body request: ListItemRequest): ApiResponse<ListItemResponse>
@POST("/warehouse/get_item")
suspend fun getItem(@Body request: GetItemRequest): ApiResponse<ItemResponse>
@POST("/warehouse/add_item")
suspend fun addItem(@Body request: Map<String, Any>): ApiResponse<Any>
@POST("/warehouse/update_item")
suspend fun updateItem(@Body request: Map<String, Any>): ApiResponse<Any>
@POST("/warehouse/delete_item")
suspend fun deleteItem(@Body request: Map<String, Int>): ApiResponse<Any>
@POST("/warehouse/move_item")
suspend fun moveItem(@Body request: MoveItemRequest): ApiResponse<Any>
@POST("/warehouse/get_stats")
suspend fun getStats(@Body request: Map<String, Any> = emptyMap()): ApiResponse<Stats>
}
@@ -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<WorkOrder>?,
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<String>? = null
)
data class SearchPurchaseOrdersRequest(
val search: String,
val limit: Int = 10
)
data class SearchPurchaseOrdersResponse(
val purchase_orders: List<PurchaseOrder>?
)
interface WorkOrderApi {
@POST("/work_order/list")
suspend fun list(@Body request: WorkOrderListRequest): ApiResponse<WorkOrderListResponse>
@POST("/work_order/get")
suspend fun get(@Body request: WorkOrderGetRequest): ApiResponse<WorkOrderResponse>
@POST("/work_order/count")
suspend fun getCount(@Body request: Map<String, Any> = emptyMap()): ApiResponse<WorkOrderCounts>
@POST("/work_order/add")
suspend fun add(@Body request: Map<String, Any>): ApiResponse<Any>
@POST("/work_order/update")
suspend fun update(@Body request: Map<String, Any>): ApiResponse<Any>
@POST("/work_order/delete")
suspend fun delete(@Body request: Map<String, Int>): ApiResponse<Any>
@POST("/work_order/commit")
suspend fun commit(@Body request: WorkOrderCommitRequest): ApiResponse<Any>
@POST("/work_order/search_purchase_orders")
suspend fun searchPurchaseOrders(@Body request: SearchPurchaseOrdersRequest): ApiResponse<SearchPurchaseOrdersResponse>
}
@@ -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<Cookie> {
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<Unit> {
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<Pair<com.example.ops_android.data.model.UserInfo?, UserDetail?>> {
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<Unit> {
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<Unit> {
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<Unit> {
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
}
}
@@ -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<List<Customer>> {
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<Customer> {
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<String, Any>): Result<Unit> {
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<String, Any>): Result<Unit> {
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<Unit> {
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)
}
}
}
@@ -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<Pair<List<PurchaseOrder>, 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<PurchaseOrder> {
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<OrderCounts> {
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<String>? = null): Result<Unit> {
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<String, Any>): Result<Unit> {
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<String, Any>): Result<Unit> {
return try {
val response = purchaseApi.updateOrder(data)
if (response.errCode == 0) Result.success(Unit)
else Result.failure(Exception("更新订单失败"))
} catch (e: Exception) {
Result.failure(e)
}
}
}
@@ -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<List<ScheduleEvent>> {
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<String, Any>): Result<Unit> {
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<String, Any>): Result<Unit> {
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<Unit> {
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)
}
}
}
@@ -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<List<Container>> {
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<Container> {
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<String, Any>): Result<Unit> {
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<String, Any>): Result<Unit> {
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<Unit> {
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<List<Item>> {
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<Item> {
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<String, Any>): Result<Unit> {
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<String, Any>): Result<Unit> {
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<Unit> {
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<Unit> {
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<Stats> {
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)
}
}
}
@@ -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<Pair<List<WorkOrder>, 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<WorkOrder> {
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<WorkOrderCounts> {
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<String, Any>): Result<Unit> {
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<String, Any>): Result<Unit> {
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<Unit> {
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<String>?): Result<Unit> {
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<List<PurchaseOrder>> {
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)
}
}
}
@@ -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<Unit> = 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<Unit> = 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<Unit> = 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<Unit> = 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)
}
}
}
@@ -0,0 +1,8 @@
package com.example.ops_android.ui
import androidx.compose.runtime.compositionLocalOf
import com.example.ops_android.AppContainer
val LocalAppContainer = compositionLocalOf<AppContainer> {
error("AppContainer not provided")
}
@@ -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))
}
}
}
@@ -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<LoginUiState> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return LoginViewModel(authRepository) as T
}
}
}
@@ -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<Customer> = 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<CustomerListUiState> = _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 <T : ViewModel> create(c: Class<T>): 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("取消") } }
)
}
@@ -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)
}
}
}
@@ -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<ScheduleEvent> = 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<HomeUiState> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return HomeViewModel(orderRepository, workOrderRepository, scheduleRepository) as T
}
}
}
@@ -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) }
)
}
}
}
}
@@ -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)
}
}
}
}
}
@@ -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<OrderDetailUiState> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return OrderDetailViewModel(orderRepository, orderId) as T
}
}
}
@@ -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<Pair<String, String>> = 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<OrderFormUiState> = _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 <T : ViewModel> create(modelClass: Class<T>): 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("提交")
}
}
}
}
@@ -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
)
}
}
}
}
@@ -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<PurchaseOrder> = 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<OrderListUiState> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return OrderListViewModel(orderRepository) as T
}
}
}
@@ -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)
}
}
@@ -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<ProfileUiState> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return ProfileViewModel(authRepository, sessionManager) as T
}
}
}
@@ -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<List<Int?>>()
var currentWeek = mutableListOf<Int?>()
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("取消") } }
)
}
@@ -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<ScheduleEvent> = 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<ScheduleUiState> = _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 <T : ViewModel> create(c: Class<T>): T = ScheduleViewModel(repo) as T
}
}
@@ -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()
}
}
@@ -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<PurchaseOrder> = emptyList(),
val workOrders: List<WorkOrder> = emptyList(),
val items: List<Item> = emptyList(),
val containers: List<Container> = 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<SearchUiState> = _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<SearchCategory, String> {
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 <T : ViewModel> create(modelClass: Class<T>): T {
return SearchViewModel(orderRepository, workOrderRepository, warehouseRepository) as T
}
}
}
@@ -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)
}
}
@@ -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<SettingsUiState> = _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 <T : ViewModel> create(modelClass: Class<T>): T {
return SettingsViewModel(sessionManager) as T
}
}
}
@@ -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<ItemDetailUiState> = _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 <T : ViewModel> create(c: Class<T>): 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) }
}
@@ -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<ItemFormUiState> = _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 <T : ViewModel> create(c: Class<T>): 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("提交") }
}
}
}
@@ -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)
}
}
}
@@ -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<Container> = emptyList(),
val items: List<Item> = emptyList(),
val breadcrumb: List<Pair<Int, String>> = 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<WarehouseUiState> = _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 <T : ViewModel> create(c: Class<T>): T = WarehouseViewModel(repo) as T
}
}
@@ -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<WorkOrderDetailUiState> = _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 <T : ViewModel> create(c: Class<T>): 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) }
}
@@ -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<WorkOrderFormUiState> = _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 <T : ViewModel> create(c: Class<T>): 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("提交")
}
}
}
}
@@ -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() } }
}
}
}
}
@@ -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<WorkOrder> = 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<WorkOrderListUiState> = _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 <T : ViewModel> create(modelClass: Class<T>): T = WorkOrderListViewModel(repo) as T
}
}
+2 -2
View File
@@ -1,3 +1,3 @@
#Wed Jun 24 20:47:38 CST 2026 #Wed Jun 24 22:02:35 CST 2026
VERSION_CODE=23 VERSION_CODE=45
VERSION_NAME=1.2 VERSION_NAME=1.2
+1 -2
View File
@@ -1,5 +1,4 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins { plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
} }
+1 -1
View File
@@ -16,4 +16,4 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# has changed. Additionally, Gradle applies performance optimizations to task execution. # has changed. Additionally, Gradle applies performance optimizations to task execution.
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
# Kotlin code style for this project: "official" or "obsolete": # Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official kotlin.code.style=official
+27 -1
View File
@@ -8,6 +8,15 @@ lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0" activityCompose = "1.8.0"
kotlin = "2.2.10" kotlin = "2.2.10"
composeBom = "2026.02.01" 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] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } 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-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }