niuma-codex 0.0.0-stage → 0.0.1-beta.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +68 -2
- package/THIRD_PARTY_NOTICES +10 -0
- package/bin/niuma.js +3 -0
- package/dist/assets/control/app.js +1858 -0
- package/dist/assets/control/index.html +309 -0
- package/dist/assets/pairing-web/PHOSPHOR-LICENSE.txt +21 -0
- package/dist/assets/pairing-web/app.js +180 -0
- package/dist/assets/pairing-web/index.html +28 -0
- package/dist/assets/pairing-web/settings.js +66 -0
- package/dist/assets/pairing-web/state.js +28 -0
- package/dist/assets/pairing-web/style.css +98 -0
- package/dist/cli.js +87530 -0
- package/package.json +38 -4
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Niuma Desktop Control</title>
|
|
7
|
+
<link rel="stylesheet" href="/bootstrap.min.css">
|
|
8
|
+
<style>
|
|
9
|
+
/* 基于 Bootstrap 开源组件统一控制台主题,保留原有交互与响应式断点。 */
|
|
10
|
+
:root {
|
|
11
|
+
--bs-body-font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
12
|
+
--bs-body-font-size: .875rem;
|
|
13
|
+
--bs-body-color: #243247;
|
|
14
|
+
--bs-secondary-color: #64748b;
|
|
15
|
+
--bs-border-color: #e2e8f0;
|
|
16
|
+
--bs-tertiary-bg: #f4f6fa;
|
|
17
|
+
--bs-link-color: #405cc7;
|
|
18
|
+
--bs-link-hover-color: #2c449e;
|
|
19
|
+
}
|
|
20
|
+
body { line-height: 1.6; }
|
|
21
|
+
/* 桌面导航固定宽度,避免大屏栅格放大侧栏和正文之间的留白。 */
|
|
22
|
+
aside .navbar { padding: 1.5rem 1rem !important; }
|
|
23
|
+
.navbar-brand { font-size: 1.05rem; letter-spacing: -.035em; padding: .5rem; }
|
|
24
|
+
aside .nav-link { color: #526179; padding: .7rem .9rem; font-weight: 500; border-radius: .55rem; }
|
|
25
|
+
aside .nav-link:hover { background: #f1f4f9; color: #2e469f; }
|
|
26
|
+
aside .nav-pills .nav-link.active { background: #eaf0ff; color: #354fae; box-shadow: inset 3px 0 #526bce; }
|
|
27
|
+
aside .small { padding-left: .9rem; font-size: .72rem; letter-spacing: .08em; }
|
|
28
|
+
main { min-width: 0; }
|
|
29
|
+
main > .container-xl { max-width: 1440px; padding: 2rem !important; }
|
|
30
|
+
main header { border-bottom: 0 !important; padding-bottom: .25rem !important; }
|
|
31
|
+
#control-page-title { font-size: 1.8rem; font-weight: 700; letter-spacing: -.04em; }
|
|
32
|
+
#control-page-intro { font-size: .875rem; line-height: 1.7; max-width: 70ch; }
|
|
33
|
+
#refresh-status { font-size: .75rem !important; }
|
|
34
|
+
.card { --bs-card-border-color: #e2e8f0; --bs-card-border-radius: .85rem; }
|
|
35
|
+
.card.shadow-sm { box-shadow: 0 2px 6px #24324704 !important; }
|
|
36
|
+
.card-body { padding: 1.4rem; }
|
|
37
|
+
h2.h4 { font-size: 1rem; font-weight: 650; }
|
|
38
|
+
h3.h5 { font-size: 1rem; font-weight: 650; }
|
|
39
|
+
.btn { --bs-btn-font-size: .8125rem; --bs-btn-border-radius: .45rem; --bs-btn-padding-y: .45rem; --bs-btn-padding-x: .85rem; font-weight: 500; }
|
|
40
|
+
.btn-primary { --bs-btn-bg: #405cc7; --bs-btn-border-color: #405cc7; --bs-btn-hover-bg: #344caa; --bs-btn-hover-border-color: #344caa; --bs-btn-active-bg: #2c449e; --bs-btn-active-border-color: #2c449e; }
|
|
41
|
+
.btn-outline-secondary { --bs-btn-color: #526179; --bs-btn-border-color: #ced7e4; --bs-btn-hover-color: #243247; --bs-btn-hover-bg: #edf1f7; --bs-btn-hover-border-color: #b6c3d6; }
|
|
42
|
+
.form-control, .form-select { font-size: .8125rem; border-radius: .45rem; min-height: 2.25rem; }
|
|
43
|
+
.form-label { color: #526179; font-size: .75rem; font-weight: 600; }
|
|
44
|
+
.badge { font-weight: 600; padding: .4em .65em; border-radius: .35rem; }
|
|
45
|
+
/* 长标识符可换行;限制字段密度,避免诊断内容撑破卡片。 */
|
|
46
|
+
dd { overflow-wrap: anywhere; font-size: .8125rem; line-height: 1.65; }
|
|
47
|
+
dt { font-size: .75rem; font-weight: 500; color: #64748b; margin-bottom: .25rem; }
|
|
48
|
+
.font-monospace { font-size: .8125rem; }
|
|
49
|
+
.session > dl { background: #f7f9fc; padding: .25rem .8rem .9rem; margin-inline: 0; border-radius: .5rem; }
|
|
50
|
+
details.border { border-color: #e2e8f0 !important; border-radius: .6rem !important; padding: 1rem !important; }
|
|
51
|
+
details > summary { color: #43536a; font-size: .8125rem; }
|
|
52
|
+
details[open] > summary { border-bottom: 1px solid #e8edf4; padding-bottom: .8rem; margin-bottom: .3rem; }
|
|
53
|
+
details details { background: #fafbfd; }
|
|
54
|
+
.alert { --bs-alert-border-radius: .55rem; font-size: .8125rem; }
|
|
55
|
+
.alert-secondary { --bs-alert-bg: #f1f5fa; --bs-alert-border-color: #e0e7f0; --bs-alert-color: #43536a; }
|
|
56
|
+
.list-group-item { border-color: #edf0f5; padding: 1rem; font-size: .8125rem; }
|
|
57
|
+
.table { --bs-table-bg: transparent; font-size: .8125rem; }
|
|
58
|
+
.table th { color: #64748b; font-size: .75rem; font-weight: 600; background: #f7f9fc; }
|
|
59
|
+
.table > :not(caption) > * > * { padding: .85rem; }
|
|
60
|
+
pre { background: #f5f7fb; border: 1px solid #e2e8f0; border-radius: .5rem; padding: 1rem; font-size: .75rem; }
|
|
61
|
+
:is(a, button, summary):focus-visible { outline: 3px solid #8b9fe4; outline-offset: 3px; }
|
|
62
|
+
@media (min-width: 992px) {
|
|
63
|
+
body > .container-fluid > .row > aside { width: 232px; flex: 0 0 232px; }
|
|
64
|
+
body > .container-fluid > .row > main { width: calc(100% - 232px); flex: 1; }
|
|
65
|
+
aside .navbar { min-height: 100vh; justify-content: flex-start; }
|
|
66
|
+
aside .navbar-collapse { flex-grow: 0; align-items: flex-start; }
|
|
67
|
+
}
|
|
68
|
+
@media (max-width: 991.98px) {
|
|
69
|
+
main > .container-xl { padding: 1.25rem !important; }
|
|
70
|
+
aside .navbar { padding: .75rem 1rem !important; }
|
|
71
|
+
aside { border-bottom: 1px solid var(--bs-border-color); }
|
|
72
|
+
}
|
|
73
|
+
@media (max-width: 575.98px) {
|
|
74
|
+
.card-body { padding: 1rem; }
|
|
75
|
+
#control-page-title { font-size: 1.5rem; }
|
|
76
|
+
main > .container-xl { padding: 1rem !important; }
|
|
77
|
+
}
|
|
78
|
+
</style>
|
|
79
|
+
<script src="/bootstrap.bundle.min.js" defer></script>
|
|
80
|
+
<script src="/chart.umd.js" defer></script>
|
|
81
|
+
<script src="/app.js" defer></script>
|
|
82
|
+
</head>
|
|
83
|
+
<body class="bg-body-tertiary">
|
|
84
|
+
<div class="container-fluid p-0">
|
|
85
|
+
<div class="row g-0 min-vh-100">
|
|
86
|
+
<aside class="col-12 col-lg-3 col-xl-2 border-end bg-body">
|
|
87
|
+
<nav class="navbar navbar-expand-lg flex-lg-column align-items-lg-stretch p-3 sticky-lg-top" aria-label="Desktop Control 导航">
|
|
88
|
+
<a class="navbar-brand fw-semibold text-wrap me-0" href="/">Niuma Desktop Control</a>
|
|
89
|
+
<button
|
|
90
|
+
class="navbar-toggler"
|
|
91
|
+
type="button"
|
|
92
|
+
data-bs-toggle="collapse"
|
|
93
|
+
data-bs-target="#control-navigation"
|
|
94
|
+
aria-controls="control-navigation"
|
|
95
|
+
aria-expanded="false"
|
|
96
|
+
aria-label="切换导航"
|
|
97
|
+
>
|
|
98
|
+
<span class="navbar-toggler-icon"></span>
|
|
99
|
+
</button>
|
|
100
|
+
<div id="control-navigation" class="collapse navbar-collapse w-100">
|
|
101
|
+
<div class="w-100 pt-3 pt-lg-4">
|
|
102
|
+
<p class="small text-body-secondary mb-3">本机管理控制台</p>
|
|
103
|
+
<div class="nav nav-pills flex-column gap-1">
|
|
104
|
+
<a id="control-nav-overview" class="nav-link" href="/">概览</a>
|
|
105
|
+
<a id="control-nav-connections" class="nav-link" href="/connections">外部连接</a>
|
|
106
|
+
<a id="control-nav-sessions" class="nav-link" href="/sessions">会话跟踪</a>
|
|
107
|
+
<a id="control-nav-subscriptions" class="nav-link" href="/subscriptions">会话订阅</a>
|
|
108
|
+
<a id="control-nav-turn-audit" class="nav-link" href="/turn-audit">Turn 推送审计</a>
|
|
109
|
+
<a id="control-nav-logs" class="nav-link" href="/logs">诊断日志</a>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</nav>
|
|
114
|
+
</aside>
|
|
115
|
+
|
|
116
|
+
<main class="col-12 col-lg-9 col-xl-10">
|
|
117
|
+
<div class="container-xl py-4 py-lg-5">
|
|
118
|
+
<header class="d-flex flex-column flex-sm-row justify-content-sm-between align-items-sm-start gap-3 border-bottom pb-4 mb-4">
|
|
119
|
+
<div>
|
|
120
|
+
<p class="small text-uppercase text-body-secondary fw-semibold mb-2">本机管理控制台</p>
|
|
121
|
+
<h1 id="control-page-title" class="display-6 mb-2"></h1>
|
|
122
|
+
<p id="control-page-intro" class="lead text-body-secondary mb-0"></p>
|
|
123
|
+
</div>
|
|
124
|
+
<button id="refresh-control" class="btn btn-primary flex-shrink-0" type="button">刷新当前页</button>
|
|
125
|
+
</header>
|
|
126
|
+
|
|
127
|
+
<p id="refresh-status" class="small text-body-secondary mb-4" role="status" aria-live="polite"></p>
|
|
128
|
+
|
|
129
|
+
<section id="control-page-overview" class="control-page">
|
|
130
|
+
<section class="card shadow-sm mb-4" aria-labelledby="overview-heading">
|
|
131
|
+
<div class="card-body">
|
|
132
|
+
<h2 id="overview-heading" class="h4 mb-4">运行概览</h2>
|
|
133
|
+
<dl id="overview" class="row row-cols-1 row-cols-sm-2 row-cols-xl-3 g-3 mb-0" aria-live="polite">
|
|
134
|
+
<div class="col"><dt class="small text-body-secondary">状态</dt><dd class="font-monospace text-break mb-0">正在读取…</dd></div>
|
|
135
|
+
</dl>
|
|
136
|
+
<p id="empty-state" class="alert alert-secondary mb-0 mt-4" hidden>daemon 未启动</p>
|
|
137
|
+
</div>
|
|
138
|
+
</section>
|
|
139
|
+
|
|
140
|
+
<section class="card shadow-sm mb-4" aria-labelledby="daemon-metrics-heading">
|
|
141
|
+
<div class="card-body">
|
|
142
|
+
<div class="d-flex flex-column flex-lg-row justify-content-lg-between align-items-lg-start gap-3 mb-3">
|
|
143
|
+
<div>
|
|
144
|
+
<h2 id="daemon-metrics-heading" class="h4 mb-1">daemon 资源</h2>
|
|
145
|
+
<p id="daemon-metrics-subtitle" class="text-body-secondary mb-0">正在读取 CPU 与内存采样…</p>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="d-flex flex-wrap align-items-center gap-2">
|
|
148
|
+
<div class="d-flex flex-wrap gap-1" role="group" aria-label="daemon 资源时间范围">
|
|
149
|
+
<button id="daemon-metrics-range-5m" class="btn btn-outline-secondary" type="button" data-daemon-metrics-range="5m">5 分钟</button>
|
|
150
|
+
<button id="daemon-metrics-range-15m" class="btn btn-outline-secondary active" type="button" data-daemon-metrics-range="15m" aria-pressed="true">15 分钟</button>
|
|
151
|
+
<button id="daemon-metrics-range-1h" class="btn btn-outline-secondary" type="button" data-daemon-metrics-range="1h">1 小时</button>
|
|
152
|
+
<button id="daemon-metrics-range-6h" class="btn btn-outline-secondary" type="button" data-daemon-metrics-range="6h">6 小时</button>
|
|
153
|
+
<button id="daemon-metrics-range-24h" class="btn btn-outline-secondary" type="button" data-daemon-metrics-range="24h">24 小时</button>
|
|
154
|
+
<button id="daemon-metrics-range-48h" class="btn btn-outline-secondary" type="button" data-daemon-metrics-range="48h">48 小时</button>
|
|
155
|
+
</div>
|
|
156
|
+
<span id="daemon-metrics-status" class="badge text-bg-secondary">unknown</span>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
<dl id="daemon-metrics-summary" class="row row-cols-1 row-cols-sm-2 row-cols-xl-4 g-3 mb-4" aria-live="polite"></dl>
|
|
160
|
+
<div class="row g-3">
|
|
161
|
+
<div class="col-12 col-xl-6">
|
|
162
|
+
<div class="border rounded p-3">
|
|
163
|
+
<h3 id="daemon-cpu-chart-title" class="h6 text-body-secondary">CPU 最近 15 分钟</h3>
|
|
164
|
+
<div style="height: 220px; position: relative"><canvas id="daemon-cpu-chart" role="img" aria-label="CPU 最近 15 分钟趋势"></canvas></div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="col-12 col-xl-6">
|
|
168
|
+
<div class="border rounded p-3">
|
|
169
|
+
<h3 id="daemon-memory-chart-title" class="h6 text-body-secondary">RSS 最近 15 分钟</h3>
|
|
170
|
+
<div style="height: 220px; position: relative"><canvas id="daemon-memory-chart" role="img" aria-label="RSS 最近 15 分钟趋势"></canvas></div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
<p id="daemon-metrics-empty" class="alert alert-secondary mb-0 mt-4" hidden>暂无 daemon 资源采样。</p>
|
|
175
|
+
</div>
|
|
176
|
+
</section>
|
|
177
|
+
|
|
178
|
+
<section id="daemon-lifecycle" class="card shadow-sm mb-4" aria-labelledby="daemon-lifecycle-heading">
|
|
179
|
+
<div class="card-body">
|
|
180
|
+
<h2 id="daemon-lifecycle-heading" class="h4">daemon 生命周期</h2>
|
|
181
|
+
<p class="text-body-secondary">仅管理当前设备上的 daemon;停止和重启需要手动确认。</p>
|
|
182
|
+
<dl id="daemon-lifecycle-details" class="row row-cols-1 row-cols-sm-2 g-3 mb-0" aria-live="polite">
|
|
183
|
+
<div class="col"><dt class="small text-body-secondary">状态</dt><dd class="font-monospace text-break mb-0">正在读取…</dd></div>
|
|
184
|
+
</dl>
|
|
185
|
+
<div class="d-flex flex-wrap gap-2 mt-4">
|
|
186
|
+
<button id="daemon-start" class="btn btn-primary" type="button">启动</button>
|
|
187
|
+
<button id="daemon-stop" class="btn btn-outline-danger" type="button">停止</button>
|
|
188
|
+
<button id="daemon-restart" class="btn btn-outline-secondary" type="button">重启</button>
|
|
189
|
+
</div>
|
|
190
|
+
<div class="row g-2 align-items-end mt-3">
|
|
191
|
+
<div class="col-sm">
|
|
192
|
+
<label id="daemon-action-confirmation-label" class="form-label" for="daemon-action-confirmation" hidden></label>
|
|
193
|
+
<input id="daemon-action-confirmation" class="form-control" type="text" autocomplete="off" aria-label="生命周期操作确认" hidden>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="col-sm-auto">
|
|
196
|
+
<button id="daemon-action-submit" class="btn btn-danger" type="button" hidden disabled>确认执行</button>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
</section>
|
|
201
|
+
</section>
|
|
202
|
+
|
|
203
|
+
<section id="control-page-connections" hidden aria-labelledby="connections-heading">
|
|
204
|
+
<h2 id="connections-heading" class="h4">当前设备连接</h2>
|
|
205
|
+
<p class="text-body-secondary">同一设备的 Direct 与 Relay 共享 4 个业务名额。探测会话不占额度;正在释放的会话会保留至资源清理完成。已释放会话不保留记录。</p>
|
|
206
|
+
<p id="connections-status" class="small text-body-secondary" role="status" aria-live="polite">正在读取…</p>
|
|
207
|
+
<p id="connections-empty" class="alert alert-secondary" hidden>当前没有保留的外部连接会话。</p>
|
|
208
|
+
<div id="connections-current" class="d-grid gap-4"></div>
|
|
209
|
+
</section>
|
|
210
|
+
|
|
211
|
+
<section id="control-page-sessions" class="control-page" hidden>
|
|
212
|
+
<section id="sessions-section" class="card shadow-sm mb-4" aria-labelledby="sessions-heading">
|
|
213
|
+
<div class="card-body">
|
|
214
|
+
<h2 id="sessions-heading" class="h4">会话跟踪</h2>
|
|
215
|
+
<p class="text-body-secondary">快照与租约仅表示 follower 生命周期,不代表 writer lock 或写权限。</p>
|
|
216
|
+
<div id="sessions-list" class="vstack gap-3" aria-live="polite"></div>
|
|
217
|
+
<p id="sessions-empty" class="text-body-secondary text-center py-4 mb-0" hidden>当前没有会话跟踪记录。</p>
|
|
218
|
+
</div>
|
|
219
|
+
</section>
|
|
220
|
+
</section>
|
|
221
|
+
|
|
222
|
+
<section id="control-page-subscriptions" class="control-page" hidden>
|
|
223
|
+
<section id="follower-traces-section" class="card shadow-sm mb-4" aria-labelledby="follower-traces-heading">
|
|
224
|
+
<div class="card-body">
|
|
225
|
+
<h2 id="follower-traces-heading" class="h4">会话订阅 Trace(最近 24 小时)</h2>
|
|
226
|
+
<p class="text-body-secondary">记录 daemon 会话订阅,以及旧版外部打开会话的安全诊断步骤。</p>
|
|
227
|
+
<button id="clear-follower-traces" class="btn btn-outline-danger" type="button">清空会话订阅 Trace</button>
|
|
228
|
+
<p id="follower-traces-coverage" class="small text-body-secondary mt-3" aria-live="polite"></p>
|
|
229
|
+
<div id="follower-traces-list" class="vstack gap-3" aria-live="polite"></div>
|
|
230
|
+
<p id="follower-traces-empty" class="text-body-secondary text-center py-4 mb-0" hidden>最近 24 小时没有会话订阅 Trace。</p>
|
|
231
|
+
</div>
|
|
232
|
+
</section>
|
|
233
|
+
</section>
|
|
234
|
+
|
|
235
|
+
<section id="control-page-turn-audit" class="control-page" hidden>
|
|
236
|
+
<section id="turn-audit-section" class="card shadow-sm mb-4" aria-labelledby="turn-audit-heading">
|
|
237
|
+
<div class="card-body">
|
|
238
|
+
<h2 id="turn-audit-heading" class="h4">Turn 推送审计</h2>
|
|
239
|
+
<p class="text-body-secondary">仅展示最近 24 小时的安全证据快照;汇总按窗口内全部 Turn 统计。</p>
|
|
240
|
+
<p id="turn-audit-coverage" class="alert alert-warning" role="status" aria-live="polite">正在读取覆盖范围…</p>
|
|
241
|
+
<h3 class="h6 text-body-secondary">Turn 终态汇总</h3>
|
|
242
|
+
<div id="turn-audit-summary" class="row row-cols-1 row-cols-sm-2 row-cols-xl-4 g-2 mb-4" aria-label="Turn 推送审计汇总">
|
|
243
|
+
<div class="col"><span id="turn-audit-summary-terminal" class="badge text-bg-secondary text-wrap text-start w-100 p-2">终态 0</span></div>
|
|
244
|
+
<div class="col"><span id="turn-audit-summary-push-required" class="badge text-bg-secondary text-wrap text-start w-100 p-2">应推送 0</span></div>
|
|
245
|
+
<div class="col"><span id="turn-audit-summary-not-required" class="badge text-bg-secondary text-wrap text-start w-100 p-2">无需推送 0</span></div>
|
|
246
|
+
<div class="col"><span id="turn-audit-summary-server-accepted" class="badge text-bg-secondary text-wrap text-start w-100 p-2">Server 已接受 0</span></div>
|
|
247
|
+
<div class="col"><span id="turn-audit-summary-bridge-not-observed" class="badge text-bg-secondary text-wrap text-start w-100 p-2">未观察 Bridge 0</span></div>
|
|
248
|
+
<div class="col"><span id="turn-audit-summary-send-failed" class="badge text-bg-secondary text-wrap text-start w-100 p-2">发送失败 0</span></div>
|
|
249
|
+
<div class="col"><span id="turn-audit-summary-awaiting-bridge" class="badge text-bg-secondary text-wrap text-start w-100 p-2">等待 Bridge 0</span></div>
|
|
250
|
+
<div class="col"><span id="turn-audit-summary-unverifiable" class="badge text-bg-secondary text-wrap text-start w-100 p-2">缺少投递证据 0</span></div>
|
|
251
|
+
</div>
|
|
252
|
+
<div class="d-flex flex-column flex-sm-row align-items-sm-center justify-content-between gap-3 mb-3">
|
|
253
|
+
<div class="btn-group" role="group" aria-label="推送审计展示方式">
|
|
254
|
+
<button id="turn-audit-view-events" class="btn btn-primary" type="button" aria-pressed="true">按触发时间</button>
|
|
255
|
+
<button id="turn-audit-view-sessions" class="btn btn-outline-primary" type="button" aria-pressed="false">按会话分组</button>
|
|
256
|
+
</div>
|
|
257
|
+
<p id="turn-audit-event-count" class="small text-body-secondary mb-0" role="status"></p>
|
|
258
|
+
</div>
|
|
259
|
+
<div id="trigger-filters" class="mb-3">
|
|
260
|
+
<div class="d-flex flex-wrap gap-2 mb-3" role="group" aria-label="推送事件状态筛选">
|
|
261
|
+
<button id="trigger-filter-all" class="btn btn-sm btn-primary" type="button" aria-pressed="true">全部</button>
|
|
262
|
+
<button id="trigger-filter-attention" class="btn btn-sm btn-outline-primary" type="button" aria-pressed="false">需排查</button>
|
|
263
|
+
<button id="trigger-filter-processing" class="btn btn-sm btn-outline-primary" type="button" aria-pressed="false">处理中</button>
|
|
264
|
+
<button id="trigger-filter-accepted" class="btn btn-sm btn-outline-primary" type="button" aria-pressed="false">Server 已接受</button>
|
|
265
|
+
<button id="trigger-filter-recovered" class="btn btn-sm btn-outline-primary" type="button" aria-pressed="false">重试后恢复</button>
|
|
266
|
+
</div>
|
|
267
|
+
<label for="trigger-filter-type" class="form-label small">触发类型</label>
|
|
268
|
+
<select id="trigger-filter-type" class="form-select form-select-sm mb-2">
|
|
269
|
+
<option value="all">全部触发类型</option>
|
|
270
|
+
<option value="pending_input">等待输入</option>
|
|
271
|
+
<option value="pending_approval">等待授权</option>
|
|
272
|
+
<option value="turn_completed">回答完成</option>
|
|
273
|
+
<option value="turn_failed">执行失败</option>
|
|
274
|
+
</select>
|
|
275
|
+
<p class="small text-body-secondary mb-0">需排查包含执行异常、推送异常和重试后恢复。Server 已接受不代表手机已收到。</p>
|
|
276
|
+
</div>
|
|
277
|
+
<p id="turn-audit-history-note" class="small text-body-secondary" hidden>历史等待事件或触发时间可能缺失,可切换“按会话分组”查看终态证据。</p>
|
|
278
|
+
<div id="turn-audit-list" class="vstack gap-3" aria-live="polite"></div>
|
|
279
|
+
<p id="turn-audit-empty" class="text-body-secondary text-center py-4 mb-0" hidden>当前没有 Turn 审计会话。</p>
|
|
280
|
+
<button id="turn-audit-load-more-sessions" class="btn btn-outline-secondary mt-3" type="button" hidden>加载更多会话</button>
|
|
281
|
+
</div>
|
|
282
|
+
</section>
|
|
283
|
+
</section>
|
|
284
|
+
|
|
285
|
+
<section id="control-page-logs" class="control-page" hidden>
|
|
286
|
+
<section class="card shadow-sm mb-4" aria-labelledby="logs-heading">
|
|
287
|
+
<div class="card-body">
|
|
288
|
+
<div class="d-flex flex-column flex-sm-row justify-content-sm-between align-items-sm-start gap-3 mb-3">
|
|
289
|
+
<div>
|
|
290
|
+
<h2 id="logs-heading" class="h4">诊断日志</h2>
|
|
291
|
+
<p class="text-body-secondary mb-0">只显示服务端安全投影后的最近日志字段。</p>
|
|
292
|
+
</div>
|
|
293
|
+
<div class="nav nav-pills" role="tablist" aria-label="日志类型">
|
|
294
|
+
<button id="error-log-tab" class="nav-link active" type="button" role="tab" aria-selected="true">错误</button>
|
|
295
|
+
<button id="debug-log-tab" class="nav-link" type="button" role="tab" aria-selected="false">调试</button>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
<div id="logs-list" class="vstack gap-3" aria-live="polite"></div>
|
|
299
|
+
<p id="logs-empty" class="text-body-secondary text-center py-4 mb-0" hidden>当前没有错误日志。</p>
|
|
300
|
+
<button id="load-more-logs" class="btn btn-outline-secondary mt-3" type="button" hidden>加载更多</button>
|
|
301
|
+
</div>
|
|
302
|
+
</section>
|
|
303
|
+
</section>
|
|
304
|
+
</div>
|
|
305
|
+
</main>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
</body>
|
|
309
|
+
</html>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2020 Phosphor Icons
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import {initialView,createPoller} from './state.js';
|
|
2
|
+
const $ = id => document.getElementById(id);
|
|
3
|
+
let generation = 0, view = 'loading', qr = null, attempt = null, page = 1, busy = false;
|
|
4
|
+
const deviceRows = new Map();
|
|
5
|
+
let nextPageDisabled = true, currentStep = 1;
|
|
6
|
+
let retry = () => loadDevices(true);
|
|
7
|
+
function notice(text='',error=false,success=false) {
|
|
8
|
+
$('notice').textContent=text; $('notice').hidden=!text; $('notice').className='notice';
|
|
9
|
+
$('notice').classList.toggle('error',error);$('notice').classList.toggle('success',success);
|
|
10
|
+
}
|
|
11
|
+
/** 只投影业务进度,不改变握手、提交或失败分类。 */
|
|
12
|
+
function step(current,attention=false){
|
|
13
|
+
currentStep=current;
|
|
14
|
+
for(let i=1;i<=3;i++){
|
|
15
|
+
const item=$(`step-${i}`);item.className=i<current?'done':i===current?`current${attention?' attention':''}`:'';
|
|
16
|
+
if(i===current)item.setAttribute('aria-current','step');else item.removeAttribute('aria-current');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function panel(name){
|
|
20
|
+
for(const id of ['qr-area','approval','status-panel'])$(id).hidden=id!==name;
|
|
21
|
+
}
|
|
22
|
+
function statusPanel(title,detail,{icon='spinner',device='',hint='',action=null,label='重试'}={}){
|
|
23
|
+
panel('status-panel');$('status-title').textContent=title;$('status-detail').textContent=detail;
|
|
24
|
+
$('status-device').textContent=device;$('status-device').hidden=!device;$('status-hint').textContent=hint;
|
|
25
|
+
$('status-icon').className=`icon icon-${icon} ${icon==='spinner'?'spinning':icon==='warning'||icon==='clock'?'warning':''}`;
|
|
26
|
+
$('status-back').hidden=!action||icon==='clock';$('retry').hidden=!action;$('retry').textContent=label;if(action)retry=action;
|
|
27
|
+
}
|
|
28
|
+
async function api(path,body,signal) {
|
|
29
|
+
const response = await fetch(`/api/v1/pairing/${path}`,{cache:'no-store',signal,
|
|
30
|
+
...(body === undefined ? {} : {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})});
|
|
31
|
+
const payload = await response.json();
|
|
32
|
+
if (!response.ok || payload.code !== 0) throw new Error(payload.message || '暂时无法完成操作,请重试');
|
|
33
|
+
return payload.data;
|
|
34
|
+
}
|
|
35
|
+
function transition(next,title,subtitle) {
|
|
36
|
+
generation++; poller.stop(); devicePoller.stop(); view=next;
|
|
37
|
+
if(next==='devices')$('title').textContent=title;
|
|
38
|
+
$('home-intro').hidden=next!=='devices';$('flow-intro').hidden=next!=='pairing';
|
|
39
|
+
$('devices').hidden=next!=='devices'; $('pairing').hidden=next!=='pairing';
|
|
40
|
+
panel(null);$('retry').hidden=true;
|
|
41
|
+
notice(); return generation;
|
|
42
|
+
}
|
|
43
|
+
function fail(error,action) {
|
|
44
|
+
statusPanel('暂时无法继续',error instanceof Error?error.message:'暂时不可用,请重试',{icon:'warning',action});
|
|
45
|
+
}
|
|
46
|
+
async function loadDevices(auto=false) {
|
|
47
|
+
if (busy) return;
|
|
48
|
+
const owner=transition('devices','已配对设备','在这里添加可连接这台电脑的设备。');
|
|
49
|
+
$('list').replaceChildren(); $('pagination').hidden=true; $('add').disabled=true;
|
|
50
|
+
try {
|
|
51
|
+
const data=await api(`devices?page=${page}&page_size=50`);
|
|
52
|
+
if(owner!==generation)return;
|
|
53
|
+
if(initialView({available:true,total:data.total})==='qr' && auto){await addDevice(false);return;}
|
|
54
|
+
renderDevices(data);
|
|
55
|
+
if(!document.hidden)devicePoller.start(true);
|
|
56
|
+
return true;
|
|
57
|
+
}catch(error){if(owner===generation)notice('设备列表读取失败,请点击“刷新列表”重试。',true);return false;}
|
|
58
|
+
finally{if(owner===generation)$('add').disabled=false;}
|
|
59
|
+
}
|
|
60
|
+
function renderDevices(data){
|
|
61
|
+
$('device-count').textContent=String(data.total);
|
|
62
|
+
const rows=[];
|
|
63
|
+
for(const device of data.list){
|
|
64
|
+
let entry=deviceRows.get(device.binding_id);
|
|
65
|
+
if(!entry){
|
|
66
|
+
const row=document.createElement('article'); row.className='device';
|
|
67
|
+
const title=document.createElement('h3');title.textContent=device.client_name;
|
|
68
|
+
const info=document.createElement('p');info.className='device-info';const d=device.device_info;
|
|
69
|
+
info.textContent=d.product==='native' ? `${d.platform==='ios'?'iOS':d.platform==='android'?'Android':d.model} ${d.system_version}` : `${d.browser} · ${d.platform}`;
|
|
70
|
+
const time=document.createElement('p');time.className='paired-time';
|
|
71
|
+
const remove=document.createElement('button');remove.type='button';remove.className='remove-device';remove.textContent='移除';
|
|
72
|
+
remove.onclick=()=>removeDevice(device,remove);
|
|
73
|
+
const icon=document.createElement('span');icon.className=`icon icon-${d.product==='native'?'phone':'laptop'}`;icon.setAttribute('aria-hidden','true');
|
|
74
|
+
remove.setAttribute('aria-label',`移除 ${device.client_name}`);
|
|
75
|
+
const badge=document.createElement('span');title.append(badge);
|
|
76
|
+
row.append(icon,title,info,time,remove);
|
|
77
|
+
entry={row,badge,time};deviceRows.set(device.binding_id,entry);
|
|
78
|
+
}
|
|
79
|
+
entry.badge.className=`connection-state ${device.connection_state==='connected'?'connected':'disconnected'}`;
|
|
80
|
+
entry.badge.textContent=device.connection_state==='connected'?'已连接':device.connection_state==='disconnected'?'未连接':'状态未知';
|
|
81
|
+
entry.time.textContent=device.last_access_at==null?'最后访问\n暂无访问记录':`最后访问\n${new Date(device.last_access_at*1000).toLocaleString('zh-CN',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hour12:false})}`;
|
|
82
|
+
rows.push(entry.row);
|
|
83
|
+
}
|
|
84
|
+
if(data.total===0){const empty=document.createElement('p');empty.className='empty';empty.textContent='当前还没有已配对设备。';rows.push(empty);}
|
|
85
|
+
$('pagination').hidden=data.total<=50; $('page-number').textContent=`第 ${page} 页`;
|
|
86
|
+
$('previous').disabled=page===1; nextPageDisabled=page*50>=data.total; $('next').disabled=nextPageDisabled;
|
|
87
|
+
// 同一页设备未增减时只改文字,保留移除按钮焦点。
|
|
88
|
+
const children=Array.from($('list').children);
|
|
89
|
+
if(children.length!==rows.length||rows.some((row,index)=>row!==children[index]))$('list').replaceChildren(...rows);
|
|
90
|
+
const ids=new Set(data.list.map(device=>device.binding_id));
|
|
91
|
+
for(const id of deviceRows.keys())if(!ids.has(id))deviceRows.delete(id);
|
|
92
|
+
}
|
|
93
|
+
/** 后台读取失败保留快照;页面切换、隐藏或写操作使迟到响应失效。 */
|
|
94
|
+
async function refreshDevices(signal){
|
|
95
|
+
if(view!=='devices'||busy||document.hidden)return;
|
|
96
|
+
const owner=generation;
|
|
97
|
+
try{
|
|
98
|
+
const data=await api(`devices?page=${page}&page_size=50`,undefined,signal);
|
|
99
|
+
if(signal.aborted||owner!==generation||busy)return;
|
|
100
|
+
renderDevices(data);
|
|
101
|
+
if($('notice').textContent==='状态更新失败,显示上次结果。')notice();
|
|
102
|
+
}catch(error){if(!signal.aborted&&owner===generation)notice('状态更新失败,显示上次结果。',true);}
|
|
103
|
+
}
|
|
104
|
+
/** 写请求完成前锁定操作;失败保留原列表,成功后重读权威快照。 */
|
|
105
|
+
async function removeDevice(device,button){
|
|
106
|
+
if(busy||!window.confirm(`移除 ${device.client_name}?该设备将无法继续访问本机,需要重新扫码配对。`))return;
|
|
107
|
+
busy=true;devicePoller.stop();button.disabled=true;$('add').disabled=true;$('refresh').disabled=true;
|
|
108
|
+
$('previous').disabled=true;$('next').disabled=true;
|
|
109
|
+
const owner=generation;notice('正在移除设备…');
|
|
110
|
+
try{
|
|
111
|
+
await api('devices/remove',{binding_id:device.binding_id});
|
|
112
|
+
if(owner!==generation)return;
|
|
113
|
+
busy=false;page=1;await loadDevices(true);
|
|
114
|
+
}catch(error){if(owner===generation)notice(error instanceof Error?error.message:'移除失败,请重试。',true);}
|
|
115
|
+
finally{
|
|
116
|
+
busy=false;button.disabled=false;$('refresh').disabled=false;
|
|
117
|
+
if(owner===generation){if(!document.hidden)devicePoller.start(true);$('add').disabled=false;$('previous').disabled=page===1;$('next').disabled=nextPageDisabled;}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function addDevice(restart=true){
|
|
121
|
+
if(busy)return;
|
|
122
|
+
const owner=transition('pairing','添加设备','使用手机 Niuma 扫描二维码,在此页面确认配对。');
|
|
123
|
+
qr=null;attempt=null;step(1);statusPanel('正在生成二维码','请稍候…');
|
|
124
|
+
try{
|
|
125
|
+
const data=await api(restart?'qr/restart':'qr/ensure',{}); if(owner!==generation)return;
|
|
126
|
+
qr=data;$('qr').src=data.qr_image_data_url;panel('qr-area');notice();
|
|
127
|
+
if(!document.hidden)poller.start();
|
|
128
|
+
}catch(error){if(owner===generation)fail(error,()=>addDevice(restart));}
|
|
129
|
+
}
|
|
130
|
+
async function poll(signal){
|
|
131
|
+
const owner=generation;if(view!=='pairing'||!qr||busy)return;
|
|
132
|
+
if(attempt){
|
|
133
|
+
const data=await api(`status?pairing_attempt_id=${encodeURIComponent(attempt)}`,undefined,signal);
|
|
134
|
+
if(signal.aborted||owner!==generation)return;
|
|
135
|
+
await showStatus(data.state);return;
|
|
136
|
+
}
|
|
137
|
+
const remaining=Math.max(0,Math.ceil(qr.expires_at-Date.now()/1000));
|
|
138
|
+
$('expiry').textContent=`${String(Math.floor(remaining/60)).padStart(2,'0')}:${String(remaining%60).padStart(2,'0')} 后过期`;
|
|
139
|
+
const data=await api(`pending?qr_payload_hash=${encodeURIComponent(qr.qr_payload_hash)}`,undefined,signal);
|
|
140
|
+
if(signal.aborted||owner!==generation)return;
|
|
141
|
+
if(data.list[0]){
|
|
142
|
+
attempt=data.list[0].pairing_attempt_id;$('client-name').textContent=data.list[0].client_name;
|
|
143
|
+
panel('approval');step(2);notice();
|
|
144
|
+
const current=await api(`status?pairing_attempt_id=${encodeURIComponent(attempt)}`,undefined,signal);
|
|
145
|
+
if(!signal.aborted&&owner===generation)await showStatus(current.state);
|
|
146
|
+
}else if(!data.active){
|
|
147
|
+
poller.stop();step(1,true);statusPanel('二维码已失效','请重新获取二维码,再使用手机 Niuma 扫码。',{icon:'clock',action:addDevice,label:'重新获取二维码',hint:'新二维码生成后,请重新扫码。'});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
async function showStatus(state){
|
|
151
|
+
if(state==='completed'){page=1;attempt=null;const loaded=await loadDevices(false);if(loaded)notice('配对成功。',false,true);return;}
|
|
152
|
+
if(state==='pending'){panel('approval');step(2);return;}
|
|
153
|
+
if(state==='finishing'){step(3);statusPanel('正在完成配对','请保持手机 Niuma 打开,稍候片刻。',{device:$('client-name').textContent,hint:'尚未完成,请勿重复扫码。'});return;}
|
|
154
|
+
if(state==='rejected'||state==='expired'||state==='missing'){
|
|
155
|
+
poller.stop();step(Math.max(2,currentStep),true);
|
|
156
|
+
statusPanel(state==='rejected'?'本次配对已结束':'配对未完成','本次配对未能完成,请重新添加设备。',{icon:'warning',action:addDevice,label:'重新添加设备',hint:'重新扫码后,需要再次允许配对。'});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function decide(decision){
|
|
160
|
+
if(busy||!attempt)return;
|
|
161
|
+
busy=true;poller.stop();$('approve').disabled=true;$('reject').disabled=true;$('back').disabled=true;
|
|
162
|
+
const owner=generation;
|
|
163
|
+
try{const data=await api(decision,{pairing_attempt_id:attempt});if(owner===generation){busy=false;await showStatus(data.state);}}
|
|
164
|
+
catch(error){if(owner===generation)fail(error,()=>{ $('retry').hidden=true;poller.start(); });}
|
|
165
|
+
finally{busy=false;$('approve').disabled=false;$('reject').disabled=false;$('back').disabled=false;if(view==='pairing'&&!document.hidden&&$('retry').hidden)poller.start();}
|
|
166
|
+
}
|
|
167
|
+
const devicePoller=createPoller(refreshDevices,{intervalMs:5000});
|
|
168
|
+
const poller=createPoller(poll,{onError:error=>fail(error,()=>{$('retry').hidden=true;poller.start();})});
|
|
169
|
+
$('status-back').onclick=()=>loadDevices(false);
|
|
170
|
+
$('add').onclick=()=>addDevice();$('refresh').onclick=()=>loadDevices(true);$('back').onclick=()=>loadDevices(false);
|
|
171
|
+
$('approve').onclick=()=>{void decide('approve');};$('reject').onclick=()=>{void decide('reject');};
|
|
172
|
+
$('retry').onclick=()=>{ void retry(); };
|
|
173
|
+
$('previous').onclick=()=>{if(busy)return;page--;void loadDevices(false);};$('next').onclick=()=>{if(busy)return;page++;void loadDevices(false);};
|
|
174
|
+
document.addEventListener('visibilitychange',()=>{
|
|
175
|
+
if(document.hidden){poller.stop();devicePoller.stop();return;}
|
|
176
|
+
if(view==='devices')devicePoller.start();
|
|
177
|
+
else if(view==='pairing'&&qr&&!busy&&$('retry').hidden)poller.start();
|
|
178
|
+
});
|
|
179
|
+
window.addEventListener('pagehide',()=>{generation++;poller.stop();devicePoller.stop();});
|
|
180
|
+
void loadDevices(true);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="color-scheme" content="dark"><title>Niuma · 设备配对</title><link rel="stylesheet" href="/style.css"></head>
|
|
4
|
+
<body>
|
|
5
|
+
<header class="topbar"><div class="brand">NIUMA</div><div class="topbar-actions"><span class="local">本机设备配对</span></div></header>
|
|
6
|
+
|
|
7
|
+
<main class="workspace">
|
|
8
|
+
<aside class="sidebar">
|
|
9
|
+
<div class="sidebar-heading"><h1>本机管理</h1><p>连接设备,管理后台服务。</p></div>
|
|
10
|
+
<nav class="workspace-nav" aria-label="本机管理导航">
|
|
11
|
+
<button id="devices-nav" class="nav-button" aria-current="page" aria-controls="device-workspace"><span class="icon icon-laptop" aria-hidden="true"></span>设备管理</button>
|
|
12
|
+
<button id="settings-toggle" class="nav-button" aria-current="false" aria-controls="settings-panel"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M4 7h16M4 17h16"/><circle cx="9" cy="7" r="3" fill="#111214"/><circle cx="15" cy="17" r="3" fill="#111214"/></svg>设置</button>
|
|
13
|
+
</nav><p class="trust">仅允许你信任的设备连接。</p>
|
|
14
|
+
</aside>
|
|
15
|
+
<div class="content">
|
|
16
|
+
<section id="settings-panel" class="settings-panel" aria-labelledby="settings-title" hidden><header class="page-heading"><h2 id="settings-title">设置</h2><p>管理这台电脑上的 Niuma。</p></header><h3 class="settings-group-title">启动与运行</h3><div class="autostart-row"><div><label for="autostart-enabled">登录后自动启动 Niuma</label><p id="autostart-description">登录这台 Mac 后自动启动后台服务。关闭后,当前服务仍会继续运行。</p><p class="settings-hint">首次开启需要下载运行文件。</p></div><input id="autostart-enabled" type="checkbox" role="switch" aria-describedby="autostart-description" disabled></div><p id="autostart-message" role="status" aria-live="polite">正在读取设置…</p><button id="autostart-retry" class="text-button" hidden>重试</button></section>
|
|
17
|
+
<div id="device-workspace">
|
|
18
|
+
<div id="home-intro"></div>
|
|
19
|
+
<div id="flow-intro" hidden><button id="back" class="text-button back"><span class="icon icon-arrow" aria-hidden="true"></span>返回设备列表</button><h1>添加设备</h1><p>使用手机 Niuma 扫码,在此页面确认配对。</p><ol class="steps" aria-label="配对进度"><li id="step-1"><span class="step-number">1</span><span>扫描二维码</span></li><li id="step-2"><span class="step-number">2</span><span>允许配对</span></li><li id="step-3"><span class="step-number">3</span><span>完成连接</span></li></ol></div>
|
|
20
|
+
<section id="devices" aria-labelledby="title"><div class="list-heading"><div class="list-title"><h2 id="title">已配对设备</h2><span id="device-count"></span></div><div class="list-actions"><button id="add" class="primary"><span class="icon icon-plus" aria-hidden="true"></span>添加设备</button><button id="refresh" class="text-button"><span class="icon icon-refresh" aria-hidden="true"></span>刷新列表</button></div></div><p id="notice" role="status" aria-live="polite" hidden></p><div id="list"></div><nav id="pagination" aria-label="设备分页" hidden><button id="previous">上一页</button><span id="page-number"></span><button id="next">下一页</button></nav></section>
|
|
21
|
+
<section id="pairing" aria-label="添加设备" hidden>
|
|
22
|
+
<div id="qr-area" class="flow-panel" hidden><h2>扫码添加设备</h2><p>打开手机 Niuma,扫描下方二维码。</p><div class="qr-surface"><img id="qr" alt="使用手机 Niuma 扫描此二维码配对"></div><p id="expiry" class="expiry"></p><p class="waiting">等待扫码</p><p class="scan-help">扫码后,请在此页面允许配对。</p></div>
|
|
23
|
+
<div id="approval" class="flow-panel" hidden><h2>允许这台设备连接?</h2><p>请确认这是你正在操作的设备。</p><span class="icon icon-phone device-portrait" aria-hidden="true"></span><h3 id="client-name"></h3><p class="permission">允许后,该设备将获得访问这台电脑的权限。</p><div class="actions"><button id="approve" class="primary">允许配对</button><button id="reject">取消本次配对</button></div><p class="hint">如果不是你的设备,请取消本次配对。</p></div>
|
|
24
|
+
<div id="status-panel" class="flow-panel status-panel" role="status" aria-live="polite" hidden><span id="status-icon" class="icon icon-spinner" aria-hidden="true"></span><h2 id="status-title"></h2><p id="status-device"></p><p id="status-detail"></p><button id="retry" class="primary" hidden>重试</button><button id="status-back" class="text-button" hidden>返回设备列表</button><p id="status-hint" class="hint"></p></div>
|
|
25
|
+
</section>
|
|
26
|
+
</div>
|
|
27
|
+
</div></main><footer>关闭网页后,后台服务仍会继续运行。</footer>
|
|
28
|
+
<script type="module" src="/app.js"></script><script type="module" src="/settings.js"></script></body></html>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** 设置使用独立请求状态,不触碰设备列表、二维码或配对轮询。 */
|
|
2
|
+
export function mountAutostartSettings(doc,win,request){
|
|
3
|
+
const $=id=>doc.getElementById(id);
|
|
4
|
+
let state=null,inFlight=false,changing=false,generation=0,timer=null;
|
|
5
|
+
const visible=()=>!$('settings-panel').hidden&&!doc.hidden;
|
|
6
|
+
function stop(){generation++;if(timer!==null)win.clearTimeout(timer);timer=null;}
|
|
7
|
+
function render(message=''){
|
|
8
|
+
const operation=state?.operation;
|
|
9
|
+
const working=changing||operation?.state==='running';
|
|
10
|
+
$('autostart-enabled').checked=state?.registered===true;
|
|
11
|
+
$('autostart-enabled').disabled=!state||!state.supported||working||inFlight;
|
|
12
|
+
$('autostart-message').textContent=message||(working?(operation?.target_enabled===false?'正在关闭…':'正在开启…'):
|
|
13
|
+
!state?'正在读取设置…':!state.supported?(state.reason||'当前环境不支持登录自启动'):
|
|
14
|
+
state.registered&&!state.available?'已配置,但暂不可用。请重新配置。':state.registered?'已开启':'未开启');
|
|
15
|
+
$('autostart-retry').hidden=working||(!message&&!(state?.registered&&!state.available));
|
|
16
|
+
$('autostart-retry').textContent=state?.registered&&!state.available?'重新配置':'重试';
|
|
17
|
+
}
|
|
18
|
+
async function load(){
|
|
19
|
+
if(inFlight||!visible())return;
|
|
20
|
+
inFlight=true;const owner=generation;render();
|
|
21
|
+
try {
|
|
22
|
+
const response=await request('/api/v1/settings/autostart',{cache:'no-store'});const payload=await response.json();
|
|
23
|
+
if(owner!==generation)return;
|
|
24
|
+
if(payload.data&&typeof payload.data.registered==='boolean')state=payload.data;
|
|
25
|
+
else if(!response.ok||payload.code!==0)throw Error(payload.message||'无法读取设置,请重试');
|
|
26
|
+
render(payload.code!==0?payload.message:'');
|
|
27
|
+
}catch(error){if(owner===generation){state=null;render(error.message||'无法读取设置,请重试');}}
|
|
28
|
+
finally{
|
|
29
|
+
inFlight=false;
|
|
30
|
+
if(owner===generation){$('autostart-enabled').disabled=!state||!state.supported||changing||state.operation?.state==='running';
|
|
31
|
+
if(visible()&&state?.operation?.state==='running')timer=win.setTimeout(()=>{timer=null;void load();},1000);
|
|
32
|
+
}else if(visible())void load();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function change(enabled){
|
|
36
|
+
if(changing||inFlight||!state?.supported||state.operation?.state==='running')return;
|
|
37
|
+
changing=true;stop();render();
|
|
38
|
+
let message='';
|
|
39
|
+
try {
|
|
40
|
+
const response=await request('/api/v1/settings/autostart',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled})});
|
|
41
|
+
const payload=await response.json();
|
|
42
|
+
if(!response.ok||payload.code!==0)message=payload.message||'设置失败,请重试';
|
|
43
|
+
}catch{message='连接中断,正在核对实际设置,请勿重复操作';}
|
|
44
|
+
finally{changing=false;await load();if(message&&visible())render(message);}
|
|
45
|
+
}
|
|
46
|
+
// 导航只切换容器可见性,保留设备与配对状态;业务响应不能抢回当前页面。
|
|
47
|
+
function navigate(settings){
|
|
48
|
+
if($('settings-panel').hidden===!settings)return;
|
|
49
|
+
$('settings-panel').hidden=!settings;
|
|
50
|
+
$('device-workspace').hidden=settings;
|
|
51
|
+
$('settings-toggle').setAttribute('aria-current',settings?'page':'false');
|
|
52
|
+
$('devices-nav').setAttribute('aria-current',settings?'false':'page');
|
|
53
|
+
stop();if(visible())void load();
|
|
54
|
+
}
|
|
55
|
+
$('settings-toggle').onclick=()=>navigate(true);
|
|
56
|
+
$('devices-nav').onclick=()=>navigate(false);
|
|
57
|
+
$('autostart-enabled').onchange=()=>{const enabled=$('autostart-enabled').checked;void change(enabled);};
|
|
58
|
+
$('autostart-retry').onclick=()=>{
|
|
59
|
+
if(['failed','interrupted'].includes(state?.operation?.state))void change(state.operation.target_enabled);
|
|
60
|
+
else if(state?.registered&&!state.available)void change(true);else void load();
|
|
61
|
+
};
|
|
62
|
+
win.addEventListener('focus',()=>{stop();if(!changing)void load();});
|
|
63
|
+
doc.addEventListener('visibilitychange',()=>{stop();if(!changing)void load();});
|
|
64
|
+
win.addEventListener('pagehide',stop);
|
|
65
|
+
}
|
|
66
|
+
if(typeof document!=='undefined')mountAutostartSettings(document,window,window.fetch.bind(window));
|