跳转至

apps\hub\src\main\java\cn\nfarms\hub\ui\TasksScreen.kt

源码路径:apps\hub\src\main\java\cn\nfarms\hub\ui\TasksScreen.kt · 185 行

package cn.nfarms.hub.ui

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cn.nfarms.core.ui.BrandErr
import cn.nfarms.core.ui.BrandOk
import cn.nfarms.core.ui.BrandWarn
import cn.nfarms.core.ui.CardBox
import cn.nfarms.core.ui.EmptyState
import cn.nfarms.core.ui.KV
import cn.nfarms.core.ui.Pill
import cn.nfarms.core.ui.Section
import cn.nfarms.core.ui.ago
import cn.nfarms.hub.HubUi
import cn.nfarms.hub.HubViewModel
import cn.nfarms.hub.task.TaskRecord
import cn.nfarms.hub.task.TaskState

/**
 * 任务页:队列与历史的唯一入口。
 *
 * 设计上刻意把「运行中」「排队」「已结束」**分成三段**而不是一个按时间排序的长列表:
 * 用户来这里是为了回答「现在卡在哪」,把一个两小时前成功的任务排在最上面
 * 只会挡住答案。
 */
@Composable
fun TasksScreen(ui: HubUi, vm: HubViewModel) {
    val running = ui.tasks.filter { it.state == TaskState.Running }
    val queued = ui.tasks.filter { it.state == TaskState.Queued }
    val finished = ui.tasks.filter { it.finished }
        .sortedByDescending { it.endedAt ?: it.createdAt }

    if (ui.tasks.isEmpty()) {
        EmptyState(
            title = "还没有任何任务",
            hint = "去「模型」下载权重,或去「媒体」跑一次 ffmpeg —— 它们都会进这条队列",
        )
        return
    }

    LazyColumn(
        modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp),
        verticalArrangement = Arrangement.spacedBy(10.dp),
        contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 12.dp),
    ) {
        if (running.isNotEmpty()) {
            item { Section("运行中", "同一时刻只会有一个 —— 重活必须串行") }
            items(running, key = { it.id }) { TaskCard(it, vm, live = true) }
        }
        if (queued.isNotEmpty()) {
            item { Section("排队中(${queued.size})", "等一下,前一个跑完自动开始") }
            items(queued, key = { it.id }) { TaskCard(it, vm, live = false) }
        }
        if (finished.isNotEmpty()) {
            item {
                Row(
                    Modifier.fillMaxWidth().padding(top = 6.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    Box(Modifier.weight(1f)) { Section("已结束", "点「清除」只清已结束的,运行中的不会动") }
                    TextButton(onClick = { vm.clearFinished() }) { Text("清除") }
                }
            }
            items(finished, key = { it.id }) { TaskCard(it, vm, live = false) }
        }
    }
}

@Composable
private fun TaskCard(t: TaskRecord, vm: HubViewModel, live: Boolean) {
    CardBox {
        Row(verticalAlignment = Alignment.CenterVertically) {
            Box(Modifier.weight(1f)) {
                Column {
                    Text(t.title, fontWeight = FontWeight.SemiBold, fontSize = 14.sp)
                    if (t.detail.isNotBlank()) {
                        Text(
                            t.detail,
                            style = MaterialTheme.typography.bodySmall,
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
                        )
                    }
                }
            }
            Spacer(Modifier.width(8.dp))
            statePill(t.state)
        }

        if (t.state == TaskState.Running) {
            Spacer(Modifier.height(8.dp))
            if (t.progress >= 0f) {
                LinearProgressIndicator(
                    progress = { t.progress },
                    modifier = Modifier.fillMaxWidth(),
                )
                Spacer(Modifier.height(3.dp))
                Text(
                    "${(t.progress * 100).toInt()}%",
                    style = MaterialTheme.typography.labelSmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
            } else {
                LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
            }
        }

        if (t.error != null) {
            Spacer(Modifier.height(6.dp))
            Text(
                t.error,
                style = MaterialTheme.typography.bodySmall,
                fontFamily = FontFamily.Monospace,
                color = BrandErr,
                fontSize = 11.sp,
            )
        }

        if (t.artifact != null) {
            Spacer(Modifier.height(6.dp))
            KV("产物", t.artifact, mono = true)
            if (t.sha256 != null) KV("SHA-256", t.sha256, mono = true, valueColor = BrandOk)
        }
        if (t.exitCode != null && t.kind == "ffmpeg") KV("退出码", t.exitCode.toString())

        Row(
            Modifier.fillMaxWidth().padding(top = 6.dp),
            horizontalArrangement = Arrangement.End,
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Text(
                ago(t.createdAt),
                style = MaterialTheme.typography.labelSmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
                modifier = Modifier.weight(1f),
            )
            when (t.state) {
                TaskState.Running, TaskState.Queued -> OutlinedButton(onClick = { vm.cancel(t.id) }) {
                    Text("取消")
                }
                TaskState.Failed -> TextButton(onClick = { vm.clearFinished() }) { Text("重试请重新发起") }
                else -> Unit
            }
        }
    }
}

@Composable
private fun statePill(s: TaskState) {
    when (s) {
        TaskState.Running -> Row(verticalAlignment = Alignment.CenterVertically) {
            CircularProgressIndicator(Modifier.size(12.dp), strokeWidth = 1.5.dp)
            Spacer(Modifier.width(5.dp))
            Pill("运行中", BrandWarn)
        }
        TaskState.Queued -> Pill("排队", MaterialTheme.colorScheme.onSurfaceVariant)
        TaskState.Done -> Pill("完成", BrandOk)
        TaskState.Failed -> Pill("失败", BrandErr)
        TaskState.Cancelled -> Pill("已取消", MaterialTheme.colorScheme.onSurfaceVariant)
    }
}