feat(client): M6 车库最小界面(浏览器只读),实测显示 15/28/515/992
browser 分支改为单页界面:连接区(URL 可改)→ 仓库物品(15 件,标注已挂载/未挂载) / 挂载位(28 槽位 + 515 设备,当前装备全 null) / 市场(992 件,只渲染前 20) / 日志区(最近 50 条,人类可读名)。状态更新用「状态容器 + 手动重渲染根节点」(未用 hooks)——全页只有一份全局会话状态。新增 GarageSession.kt(浏览器侧驱动,复用 AutoResponder 与 ClientState 的靶推导);M5 GarageDriver 的 node-only 逻辑(读抓包/日志/NodeRequire)没有拖进浏览器路径。验收:三构建成功(注意 dev 与 prod 系列要分开跑,否则 Gradle 报隐式依赖);浏览器实测已进空间 100073070、面板显示 15/28/515/992、日志含 G.q1wd/LoadObjectsData/ls.xbl/ba.lcr/devicesLoaded;服务端 session_069 佐证浏览器的上行被逐条收到;浏览器 Origin(http://127.0.0.1:8123) 被接受——服务端只记录不校验 Origin(index.js:484-489),当前无需改动。
This commit is contained in:
1 parent
c0360810b7
commit
8964ea28cd
3 files changed
+389
-26
No files matched your search
@@ -1,51 +1,121 @@
|
||||
// TankIcClient 客户端入口:用 kotlin-react 渲染一个最小但真实的页面
|
||||
// TankIcClient 客户端入口
|
||||
//
|
||||
// node 分支(jsNodeDevelopmentRun):M1–M5 的协议层自检,保持原样不动。
|
||||
// browser 分支:M6 的最小「车库」只读界面 —— 把 M5 已经能拿到的语义状态(ClientState)渲染出来。
|
||||
//
|
||||
// 界面刷新方式(M6 选的是「状态容器 + 手动重渲染」这条,没用 useState/useEffect):
|
||||
// GarageSession 每次收到帧 / 发出上行都会回调 OnChanged,这里把根节点再 render 一次,
|
||||
// React 按元素类型做协调、复用同一批 DOM 节点(所以输入框里已输入的内容不会被清掉)。
|
||||
// 为什么不走 hooks:本页面只有一份全局会话状态,用根节点重渲染语义最简单,也不引入 hook 依赖数组的心智负担。
|
||||
package TankIcClient
|
||||
|
||||
import react.FC
|
||||
import react.Props
|
||||
import react.create
|
||||
import react.dom.client.createRoot
|
||||
import react.dom.html.ReactHTML.button
|
||||
import react.dom.html.ReactHTML.div
|
||||
import react.dom.html.ReactHTML.h1
|
||||
import react.dom.html.ReactHTML.h2
|
||||
import react.dom.html.ReactHTML.input
|
||||
import react.dom.html.ReactHTML.li
|
||||
import react.dom.html.ReactHTML.p
|
||||
import react.dom.html.ReactHTML.pre
|
||||
import react.dom.html.ReactHTML.ul
|
||||
import web.cssom.ClassName
|
||||
import web.dom.ElementId
|
||||
import web.dom.document
|
||||
import web.html.HTMLElement
|
||||
import kotlinx.browser.window
|
||||
import Protocol.CompressionSelfTest
|
||||
import Protocol.ContainerDump
|
||||
import Protocol.GarageDriver
|
||||
import Protocol.GarageSession
|
||||
import Protocol.IsNodeRuntime
|
||||
import Protocol.SelfTest
|
||||
|
||||
// 浏览器侧压缩帧解压自检:页面加载即自动跑一次(同步),结果同时打到 console 与页面。
|
||||
// lazy:RenderApp 会被 window.TankIcClientMain 再调一次,避免重复解压。
|
||||
private val BrowserSelfTestLines: List<String> by lazy {
|
||||
val Report = CompressionSelfTest.RunEmbedded()
|
||||
val Lines = ArrayList<String>()
|
||||
Lines.add("压缩帧解压自检(fflate.unzlibSync,与 node 侧同一份实现)")
|
||||
Lines.addAll(Report.Lines)
|
||||
Lines.add(if (Report.Passed) "结论:✓ 通过" else "结论:✗ 未通过")
|
||||
// Kotlin/JS 的 println 就是 console.log(这里直接逐行输出到 console)
|
||||
for (L in Lines) println("TankIcClient | $L")
|
||||
Lines
|
||||
}
|
||||
// 全局会话状态容器:node 分支不会用到它(构造时不碰任何浏览器 API)
|
||||
private val Session = GarageSession { RenderApp() }
|
||||
|
||||
// 页面根组件:展示脚手架就绪信息、Kotlin/JS 版本号,以及上面那次解压自检的逐项结果
|
||||
// 页面根组件:只读渲染 Session 的快照
|
||||
private val App = FC<Props> {
|
||||
div {
|
||||
h1 { +"TankIcClient 脚手架就绪(M3:压缩帧解压)" }
|
||||
p { +"当前 Kotlin/JS 版本:${KotlinVersion.CURRENT}" }
|
||||
pre { +BrowserSelfTestLines.joinToString("\n") }
|
||||
h1 { +"TankIcClient · M6 车库(只读)" }
|
||||
|
||||
// ---------------- 1 连接区
|
||||
div {
|
||||
className = ClassName("Card")
|
||||
h2 { +"连接" }
|
||||
div {
|
||||
className = ClassName("Row")
|
||||
input {
|
||||
id = ElementId("ServerUrl")
|
||||
className = ClassName("UrlInput")
|
||||
defaultValue = Session.Url
|
||||
onChange = { E -> Session.Url = E.target.value }
|
||||
}
|
||||
button {
|
||||
onClick = { Session.Connect() }
|
||||
+"连接并进车库(100073070)"
|
||||
}
|
||||
}
|
||||
p { +"当前状态:${Session.Status}" }
|
||||
if (Session.LastError.isNotEmpty()) p { +"错误原因:${Session.LastError}" }
|
||||
}
|
||||
|
||||
// ---------------- 2 仓库物品面板(itemsOnDepot)
|
||||
div {
|
||||
className = ClassName("Card")
|
||||
h2 { +"仓库物品(itemsOnDepot):${Session.DepotItems.size} 件" }
|
||||
val Mounted = Session.MountedItems.toHashSet()
|
||||
ul {
|
||||
for (Id in Session.DepotItems) {
|
||||
li { +("$Id " + (if (Mounted.contains(Id)) "已挂载" else "未挂载")) }
|
||||
}
|
||||
}
|
||||
if (Session.DepotItems.isEmpty()) p { +"(尚未收到 itemsOnDepot;挂载依据 mountedItems 集合,共 ${Session.MountedItems.size} 件)" }
|
||||
}
|
||||
|
||||
// ---------------- 3 挂载位面板(devicesLoaded)
|
||||
div {
|
||||
className = ClassName("Card")
|
||||
h2 { +"挂载位(devicesLoaded):槽位 ${Session.Slots.size} 个 / 设备 ${Session.DeviceCount} 个" }
|
||||
ul {
|
||||
for (D in Session.Slots) {
|
||||
li { +("槽位 ${D.SlotObjectId} 设备 ${D.Devices.size} 个 当前装备:" + (D.MountedDevice ?: "未装备")) }
|
||||
}
|
||||
}
|
||||
if (Session.Slots.isEmpty()) p { +"(尚未收到 devicesLoaded)" }
|
||||
}
|
||||
|
||||
// ---------------- 4 市场面板(itemsOnMarket,只渲染前 20 个 id)
|
||||
div {
|
||||
className = ClassName("Card")
|
||||
h2 { +"市场(itemsOnMarket):共 ${Session.MarketItems.size} 件(只渲染前 20 个 id)" }
|
||||
ul {
|
||||
className = ClassName("Plain")
|
||||
for (Id in Session.MarketItems.take(20)) li { +Id }
|
||||
}
|
||||
}
|
||||
|
||||
// 把根组件渲染到 index.html 中 id 为 root 的挂载点
|
||||
// ---------------- 5 日志区(最近 MaxLogLines 条收发记录)
|
||||
div {
|
||||
className = ClassName("Card")
|
||||
h2 { +"日志:最近 ${Session.Logs.size} 条" }
|
||||
ul {
|
||||
className = ClassName("Plain Log")
|
||||
for (Line in Session.Logs) li { +Line }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// React 根节点只建一次;之后每次刷新都是对同一个根 render 一个新元素
|
||||
private var Root: dynamic = null
|
||||
|
||||
private fun RenderApp() {
|
||||
val container = document.getElementById(ElementId("root")) as? HTMLElement
|
||||
val Container = document.getElementById(ElementId("root")) as? HTMLElement
|
||||
?: error("未找到 id 为 root 的挂载节点")
|
||||
createRoot(container).render(App.create())
|
||||
if (Root == null) Root = createRoot(Container)
|
||||
Root.render(App.create())
|
||||
}
|
||||
|
||||
// 入口函数:Node 下跑协议层自检;浏览器下渲染页面并把入口挂到 window 上
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
// GarageSession.kt —— M6:浏览器侧的车库会话驱动(只读,供 React 界面渲染)
|
||||
//
|
||||
// 与 M5 GarageDriver.kt 的分工(为什么不直接搬 GarageDriver 进浏览器):
|
||||
// · GarageDriver 是 node-only 自检:读抓包文件比对、读服务端日志、ExitProcess —— 浏览器里没有 NodeRequire;
|
||||
// · 本类只保留浏览器跑得动的部分:Connection(WHATWG WebSocket)+ AutoResponder + ClientState;
|
||||
// · 靶 id 的推导规则本身在 ClientState(LsXblTargets / BaLcrTargets),两端同一份实现,本类不重写规则;
|
||||
// · 上行节拍发送沿用 M5 的口径(一次上行最多推一帧),让每条下行的触发者唯一,便于与服务端日志对照。
|
||||
//
|
||||
// 数据流:连接 → Hash Request → Hash Response → Auth/EnterSpace(100073070)
|
||||
// → s2c zt(LoadDependencies, X) → AutoResponder 自动回 G.q1wd(X)
|
||||
// → s2c pt(LoadObjectsData) → 推导 ls.xbl 靶(负数对象 id)→ 逐条发 → s2c lt(UnloadObjects)
|
||||
// → 推导 ba.lcr 靶(挂载位)→ 逐条发 → s2c zt×28 → 自动回 G.q1wd×28 → s2c pt+devicesLoaded
|
||||
//
|
||||
// 界面刷新方式:本类持有一个 OnChanged 回调,每次收到帧 / 发出上行后调用一次;
|
||||
// Main.kt 用「状态容器 + 手动重渲染根节点」的方式消费它(见 Main.kt 顶部说明)。
|
||||
package Protocol
|
||||
|
||||
// 待发送的一条上行(队列里排队等节拍;与 M5 的 PendingUplink 同名会冲突,故另起名)
|
||||
internal class QueuedUplink(val Name: String, val TargetId: String, val Bytes: ByteArray)
|
||||
|
||||
internal class GarageSession(private val OnChanged: () -> Unit) {
|
||||
|
||||
var Url: String = DefaultUrl
|
||||
var SpaceId: Long = DefaultSpaceId
|
||||
|
||||
// 界面「当前状态」:未连接 / 握手中 / 已进空间 100073070 / 错误…
|
||||
var Status: String = "未连接"
|
||||
private set
|
||||
var LastError: String = ""
|
||||
private set
|
||||
|
||||
private var Conn: Connection? = null
|
||||
private var Auto: AutoResponder? = null
|
||||
private var StateObj: ClientState = ClientState()
|
||||
|
||||
private var FrameNo = 0
|
||||
private var EnterSent = false
|
||||
private var TickStarted = false
|
||||
|
||||
private val LogLines = ArrayList<String>()
|
||||
private val Queue = ArrayList<QueuedUplink>()
|
||||
|
||||
// 靶推导的缓存(LsDerived/BaDerived 只在收到第一帧 pt 时算一次)
|
||||
private var LsDerived: List<String> = emptyList()
|
||||
private var BaDerived: List<String> = emptyList()
|
||||
private var LsEnqueued = false
|
||||
private var LsSent = 0
|
||||
private var BaEnqueued = false
|
||||
|
||||
// ---------------------------------------------------------------- 只读快照(React 渲染直接读这些)
|
||||
|
||||
val Logs: List<String> get() = LogLines
|
||||
val DepotItems: List<String> get() = StateObj.ItemsOnDepot
|
||||
val MountedItems: List<String> get() = StateObj.MountedItems
|
||||
val MarketItems: List<String> get() = StateObj.ItemsOnMarket
|
||||
val Slots: List<DevicesLoadedEntry> get() = StateObj.DevicesRecords
|
||||
val DeviceCount: Int get() = StateObj.DeviceCount
|
||||
|
||||
// ---------------------------------------------------------------- 连接
|
||||
|
||||
fun Connect() {
|
||||
Conn?.Close(1000, "重新连接")
|
||||
LogLines.clear()
|
||||
Queue.clear()
|
||||
StateObj = ClientState()
|
||||
FrameNo = 0
|
||||
EnterSent = false
|
||||
LastError = ""
|
||||
LsDerived = emptyList()
|
||||
BaDerived = emptyList()
|
||||
LsEnqueued = false
|
||||
LsSent = 0
|
||||
BaEnqueued = false
|
||||
Status = "握手中"
|
||||
AddLog("→ 建立连接 $Url(目标空间 $SpaceId)")
|
||||
|
||||
val C = Connection(Url)
|
||||
Conn = C
|
||||
Auto = AutoResponder(SpaceId) { Bytes -> EnqueueAuto(Bytes) }
|
||||
C.OnOpen = { OnOpen() }
|
||||
C.OnMessage = { OnMessage(it) }
|
||||
C.OnClose = { Code -> OnClose(Code) }
|
||||
C.OnError = { Msg ->
|
||||
LastError = Msg
|
||||
Status = "错误:$Msg"
|
||||
AddLog("✗ $Msg")
|
||||
OnChanged()
|
||||
}
|
||||
StartTickOnce()
|
||||
C.Connect()
|
||||
OnChanged()
|
||||
}
|
||||
|
||||
private fun OnOpen() {
|
||||
Status = "握手中(已连接,发 Hash Request)"
|
||||
val Req = Entry.BuildHashRequest(SelfTest.HashKeys, SelfTest.HashValues)
|
||||
AddLog("→ c2s Hash Request ${Req.size}B")
|
||||
Conn?.Send(Req)
|
||||
OnChanged()
|
||||
}
|
||||
|
||||
private fun OnClose(Code: Int) {
|
||||
AddLog("连接关闭 code=$Code")
|
||||
if (!Status.startsWith("已进空间")) Status = "未连接(连接关闭 code=$Code)"
|
||||
OnChanged()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 收帧
|
||||
|
||||
private fun OnMessage(Raw: ByteArray) {
|
||||
FrameNo++
|
||||
if (Frame.IsPingFrame(Raw)) {
|
||||
AddLog("← s2c 保活 ping ${Raw.size}B")
|
||||
OnChanged()
|
||||
return
|
||||
}
|
||||
var Off = 0
|
||||
while (Off < Raw.size) {
|
||||
val Info = Frame.PeekFrame(Raw, Off) ?: break
|
||||
val Body = try {
|
||||
Frame.DecodeFrame(WireFrame(Raw.copyOfRange(Off + Info.HeaderSize, Off + Info.TotalSize), Info.Compressed))
|
||||
} catch (E: ProtocolException) {
|
||||
AddLog("✗ 帧#$FrameNo ${Info.TotalSize}B 解压失败:${E.message}")
|
||||
OnChanged()
|
||||
return
|
||||
}
|
||||
HandleFrame(Info.TotalSize, Body)
|
||||
Off += Info.TotalSize
|
||||
}
|
||||
OnChanged()
|
||||
}
|
||||
|
||||
private fun HandleFrame(TotalBytes: Int, Body: ByteArray) {
|
||||
val NmSize = Nullmap.Size(Body, 0)
|
||||
if (NmSize >= Body.size) {
|
||||
AddLog("← s2c 空负载帧 ${TotalBytes}B")
|
||||
return
|
||||
}
|
||||
val MethodIdAt8 = if (Body.size >= NmSize + 16) Body.ReadI64(NmSize + 8).toString() else ""
|
||||
val IsSpace = ProtocolTables.Downlink(MethodIdAt8) != null || ProtocolTables.CodecByMethod(MethodIdAt8) != null
|
||||
if (!IsSpace) {
|
||||
HandleEntryFrame(TotalBytes, Body)
|
||||
return
|
||||
}
|
||||
val Parsed = SpaceFrame.Parse(Body)
|
||||
// 先落状态(M5 的语义容器),再让 AutoResponder 应答(应答只是入队,由 Tick 节拍发出)
|
||||
StateObj.OnSpaceFrame(Parsed, FrameNo, TotalBytes)
|
||||
Auto?.OnSpaceFrame(Parsed, FrameNo)
|
||||
val Names = Parsed.Records.map { RecordLabel(it) }
|
||||
AddLog("← s2c ${Names.distinct().joinToString(" + ")} ${TotalBytes}B 记录${Parsed.Records.size}")
|
||||
if (Parsed.Bad) AddLog(" ⚠ ${Parsed.Note}")
|
||||
HandlePhases(Parsed)
|
||||
}
|
||||
|
||||
private fun HandleEntryFrame(TotalBytes: Int, Body: ByteArray) {
|
||||
for (C in Entry.DecodeServerBody(Body)) {
|
||||
AddLog("← s2c 入口 ${C.Name} ${TotalBytes}B")
|
||||
if (C.Id == Entry.S2C_HASH_RESPONSE && C.Digest != null && !EnterSent) {
|
||||
EnterSent = true
|
||||
val Req = Entry.BuildEnterSpace(C.Digest, SpaceId)
|
||||
AddLog("→ c2s Auth/EnterSpace($SpaceId) ${Req.size}B")
|
||||
Conn?.Send(Req)
|
||||
Status = "已进空间 $SpaceId"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收到的帧里出现 pt(LoadObjectsData)就推导靶;推到的 ba.lcr 靶等 ls.xbl 发完再入队(顺序同真实会话)
|
||||
private fun HandlePhases(Parsed: SpaceFrameBody) {
|
||||
var HasPt = false
|
||||
var HasDevices = false
|
||||
for (R in Parsed.Records) {
|
||||
if (R.MethodId == Uplink.LOAD_OBJECTS_DATA.toString()) HasPt = true
|
||||
if (R.MethodId == M_DEVICES_LOADED) HasDevices = true
|
||||
}
|
||||
if (HasPt && !LsEnqueued) {
|
||||
LsEnqueued = true
|
||||
LsDerived = StateObj.LsXblTargets()
|
||||
BaDerived = StateObj.BaLcrTargets()
|
||||
AddLog("⟳ 由 pt 推导:ls.xbl 靶 ${LsDerived.size} 个(负数对象 id) / ba.lcr 靶 ${BaDerived.size} 个(挂载位)")
|
||||
for (Id in LsDerived) EnqueueManual(Uplink.LS_XBL, Id, Uplink.BuildLsXbl(Id.toLong()))
|
||||
}
|
||||
if (HasPt && HasDevices) {
|
||||
AddLog("✓ pt + devicesLoaded 同帧 → 车库数据到齐(槽位 ${Slots.size} / 设备 $DeviceCount)")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 上行队列与节拍
|
||||
|
||||
private fun StartTickOnce() {
|
||||
if (TickStarted) return
|
||||
TickStarted = true
|
||||
SetIntervalJs(TickMs) { Tick() }
|
||||
}
|
||||
|
||||
private fun Tick() {
|
||||
if (Queue.isNotEmpty() && Conn?.ReadyState == 1) {
|
||||
val P = Queue.removeAt(0)
|
||||
Conn?.Send(P.Bytes)
|
||||
AddLog("→ c2s ${P.Name} ${P.Bytes.size}B 靶=${P.TargetId}")
|
||||
if (P.Name == "ls.xbl") LsSent++
|
||||
OnChanged()
|
||||
}
|
||||
if (LsEnqueued && !BaEnqueued && LsSent >= LsDerived.size) {
|
||||
BaEnqueued = true
|
||||
for (Id in BaDerived) EnqueueManual(Uplink.BA_LCR, Id, Uplink.BuildBaLcr(Id.toLong()))
|
||||
AddLog("⟳ ls.xbl 已发完 → 排入 ba.lcr ${BaDerived.size} 条(靶=推导结果)")
|
||||
OnChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// AutoResponder 只给字节,名字/靶/X 从这里解出来(G.q1wd 的 X 显示成 G.q1wd(X))
|
||||
private fun EnqueueAuto(Bytes: ByteArray) {
|
||||
val D = DescribeUplink(Bytes)
|
||||
val Base = UplinkName(D.MethodId)
|
||||
Queue.add(QueuedUplink(if (D.X != null) "$Base(${D.X})" else Base, D.TargetId, Bytes))
|
||||
}
|
||||
|
||||
private fun EnqueueManual(MethodId: Long, TargetId: String, Bytes: ByteArray) {
|
||||
Queue.add(QueuedUplink(UplinkName(MethodId.toString()), TargetId, Bytes))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 日志与命名
|
||||
|
||||
private fun AddLog(Line: String) {
|
||||
LogLines.add(Line)
|
||||
while (LogLines.size > MaxLogLines) LogLines.removeAt(0)
|
||||
// 同一份日志镜像到浏览器 console(Kotlin/JS 的 println 就是 console.log),方便运维侧留痕
|
||||
println("TankIcClient | $Line")
|
||||
}
|
||||
|
||||
// 上行人类可读名:UplinkByMethod 的 Name(G.q1wd / ls.xbl / ba.lcr),查不到写「未知」
|
||||
private fun UplinkName(MethodId: String): String =
|
||||
ProtocolTables.Uplink(MethodId)?.Name?.ifEmpty { null } ?: "未知"
|
||||
|
||||
// 下行记录人类可读名:先认 M5 语义容器那 4 条(协议表里它们的 Name 是短类名,没法读),
|
||||
// 其余按 DownlinkByMethod 的 Name,查不到写「未知」
|
||||
private fun RecordLabel(R: SpaceRecord): String {
|
||||
SemanticDownlink[R.MethodId]?.let { return it }
|
||||
val Spec = ProtocolTables.Downlink(R.MethodId)
|
||||
?: return if (R.Kind == "container") "容器${R.Layout}" else "未知"
|
||||
val Tail = if (Spec.Kind == "container") "/${Spec.Layout}" else ""
|
||||
return Spec.Name.ifEmpty { "未知" } + Tail
|
||||
}
|
||||
|
||||
// 上行帧(整帧)→ 靶 id / methodId / G.q1wd 的 X
|
||||
private class UplinkDesc(val TargetId: String, val MethodId: String, val X: Int?)
|
||||
|
||||
private fun DescribeUplink(Bytes: ByteArray): UplinkDesc {
|
||||
val Frames = Frame.ParseFrames(Bytes)
|
||||
if (Frames.isNullOrEmpty()) return UplinkDesc("?", "?", null)
|
||||
val Body = Frames[0].Body
|
||||
val Nm = Nullmap.Size(Body, 0)
|
||||
if (Nm + 16 > Body.size) return UplinkDesc("?", "?", null)
|
||||
val Target = Body.ReadI64(Nm).toString()
|
||||
val Method = Body.ReadI64(Nm + 8).toString()
|
||||
val X = if (Method == Uplink.G_Q1WD.toString() && Nm + 20 <= Body.size) Body.ReadI32(Nm + 16) else null
|
||||
return UplinkDesc(Target, Method, X)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val DefaultUrl = "ws://localhost:9091/"
|
||||
const val DefaultSpaceId = 100073070L
|
||||
const val TickMs = 120
|
||||
const val MaxLogLines = 50
|
||||
const val M_DEVICES_LOADED = "7615142300796401818"
|
||||
|
||||
// M5 语义容器认识的那几条(协议表里 Name 是短类名,这里给人读名)
|
||||
val SemanticDownlink = mapOf(
|
||||
"3996477900528000389" to "itemsOnDepot",
|
||||
"5236393599344825589" to "itemsOnMarket",
|
||||
"3692495084100053385" to "mountedItems",
|
||||
"7615142300796401818" to "devicesLoaded",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,32 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TankIcClient</title>
|
||||
<title>TankIcClient · 车库</title>
|
||||
<style>
|
||||
/* 自检结果用 <pre> 展示,允许折行以免长行溢出 */
|
||||
pre { white-space: pre-wrap; word-break: break-all; }
|
||||
body { margin: 0; padding: 16px; background: #14181f; color: #d7dde6;
|
||||
font-family: "Cascadia Mono", Consolas, "Microsoft YaHei", monospace; }
|
||||
h1 { font-size: 20px; margin: 0 0 12px; }
|
||||
h2 { font-size: 14px; margin: 0 0 8px; color: #8fb7ff; }
|
||||
p { margin: 4px 0; }
|
||||
/* 面板:每块一个卡片,避免整页挤成一坨 */
|
||||
.Card { border: 1px solid #2b3442; border-radius: 6px; padding: 10px 12px; margin-bottom: 12px; }
|
||||
.Row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
|
||||
.UrlInput { flex: 0 0 360px; background: #0f1319; color: #d7dde6;
|
||||
border: 1px solid #2b3442; border-radius: 4px; padding: 4px 6px; }
|
||||
button { background: #24405f; color: #d7dde6; border: 1px solid #3b6ea5;
|
||||
border-radius: 4px; padding: 5px 10px; cursor: pointer; }
|
||||
button:hover { background: #2d5078; }
|
||||
ul { margin: 0; padding-left: 18px; }
|
||||
li { line-height: 1.5; }
|
||||
/* 市场 id 列表与日志:去掉项目符号、等宽排版,长行折行 */
|
||||
ul.Plain { list-style: none; padding-left: 0; }
|
||||
ul.Plain li { white-space: pre-wrap; word-break: break-all; font-size: 12px; color: #b9c4d2; }
|
||||
ul.Log { max-height: 420px; overflow: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- React 挂载点,与 Main.kt 中的 id 保持一致。
|
||||
页面加载即自动跑一次「压缩帧解压自检」(内嵌真实压缩帧),结果同时渲染到这里与 console。 -->
|
||||
连接与刷新由页面上的「连接并进车库(100073070)」按钮触发,数据全部来自 WebSocket。 -->
|
||||
<div id="root"></div>
|
||||
<!-- webpack 产物名由 build.gradle.kts 的 outputFileName 决定 -->
|
||||
<script src="TankIcClient.js"></script>
|
||||
|
||||
Reference in new issue
Block a user