跳转至

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

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

package cn.nfarms.hub.ui

import androidx.compose.foundation.layout.Arrangement
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.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.BrandOk
import cn.nfarms.core.ui.BrandWarn
import cn.nfarms.core.ui.CardBox
import cn.nfarms.core.ui.KV
import cn.nfarms.core.ui.Pill
import cn.nfarms.core.ui.Section
import cn.nfarms.hub.HubUi
import cn.nfarms.hub.HubViewModel

/**
 * Harness 页。
 *
 * 真正的要点只有一个:**根地址要从进程自己的 stdout 里抓**。
 * dsh web 打印出来的地址带一次性 token,自己拼 `http://127.0.0.1:port/`
 * 打开只会 401 —— 这个坑桌面版踩过,这里不能重踩。
 */
@Composable
fun HarnessScreen(ui: HubUi, vm: HubViewModel) {
    var portText by remember { mutableStateOf(ui.harness.port.toString()) }
    val h = ui.harness

    Column(
        Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(14.dp),
        verticalArrangement = Arrangement.spacedBy(10.dp),
    ) {
        CardBox {
            Row(verticalAlignment = Alignment.CenterVertically) {
                Column(Modifier.weight(1f)) {
                    Section("ds harness", "拉起常驻进程 / 抓根地址 / 看输出 / 停止")
                }
                Pill(if (h.running) "运行中" else "已停止", if (h.running) BrandOk else MaterialTheme.colorScheme.onSurfaceVariant)
            }
            KV("实现", if (h.real) "真实 dsh" else "演示进程(sh 脚本)", valueColor = if (h.real) null else BrandWarn)
            KV("端口", h.port.toString())
            KV("根地址", h.url ?: "(还没打印)", mono = true, valueColor = if (h.url != null) BrandOk else null)

            Spacer(Modifier.height(8.dp))
            Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
                OutlinedTextField(
                    value = portText,
                    onValueChange = { portText = it.filter { c -> c.isDigit() }.take(5) },
                    label = { Text("端口") },
                    singleLine = true,
                    modifier = Modifier.weight(1f),
                )
                Button(
                    onClick = { vm.startHarness(portText.toIntOrNull() ?: 7909) },
                    enabled = !h.running,
                ) { Text("启动") }
                OutlinedButton(onClick = { vm.stopHarness() }, enabled = h.running) { Text("停止") }
            }
            if (!h.real) {
                Spacer(Modifier.height(8.dp))
                Text(
                    "本机没有 dsh,当前跑的是**演示进程**:它同样会打印一行带 token 的根地址、" +
                        "同样持续输出心跳、同样可以杀 —— 这条链路是真的,只是不做真事。",
                    style = MaterialTheme.typography.bodySmall,
                    color = BrandWarn,
                )
            }
        }

        CardBox {
            Section("进程输出", "抓到的就是进程 stdout,没有过滤")
            if (h.log.isEmpty()) {
                Text(
                    "还没有输出。点「启动」之后这里会一行行出现。",
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
            } else {
                Column(Modifier.fillMaxWidth().heightIn(max = 360.dp)) {
                    for (line in h.log) {
                        Text(
                            line,
                            style = MaterialTheme.typography.labelSmall,
                            fontFamily = FontFamily.Monospace,
                            color = MaterialTheme.colorScheme.onSurfaceVariant,
                        )
                    }
                }
            }
        }

        CardBox {
            Section("为什么地址不能自己拼", null)
            Text(
                "dsh web 打印的根地址带一次性 token。自己拼 http://127.0.0.1:<端口>/ 也能连上," +
                    "但服务端会 401 —— 而且错误信息不会告诉你「你少了个 token」。" +
                    "所以这里只认从 stdout 里正则抓到的那个地址。",
                style = MaterialTheme.typography.bodySmall,
                color = MaterialTheme.colorScheme.onSurfaceVariant,
            )
        }
    }
}