@mzzsfy/dsh-tunnel 0.1.0
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/README.md +39 -0
- package/cordis.patch.yml +9 -0
- package/package.json +42 -0
- package/src/api.mjs +116 -0
- package/src/client.js +548 -0
- package/src/index.js +695 -0
- package/test/ai-switch.test.mjs +272 -0
- package/test/api.test.mjs +145 -0
- package/test/client-id.test.mjs +25 -0
- package/test/switch-guard.test.mjs +81 -0
- package/test/tunnel.test.mjs +921 -0
package/src/client.js
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
// 隧道看板 client 半区:隧道列表/添加/删除 + 设置页插件卡片(侧边栏注入开关)。
|
|
3
|
+
// 无构建:createElement + 一次性样式注入;IIFE 书挡,顶层零词法声明(经典 script 全局词法环境跨 bundle 共享)。
|
|
4
|
+
// 开关一律 tu-switch(规约 switch 形态);全局面板与 better-sidebar tab 形态仲裁与 cron-board 同构。
|
|
5
|
+
|
|
6
|
+
/* LOGIC-BEGIN */
|
|
7
|
+
const API_PREFIX = '/api/tunnel'
|
|
8
|
+
const PANEL_ID = 'tunnel'
|
|
9
|
+
const PANEL_LABEL = '穿透隧道'
|
|
10
|
+
const TAB_ID = 'tunnel:board'
|
|
11
|
+
const TAB_ORDER = 47
|
|
12
|
+
const REFRESH_INTERVAL_MS = 30 * 1000
|
|
13
|
+
const PREF_POLL_MS = 5 * 1000
|
|
14
|
+
const COPY_FLASH_MS = 1200
|
|
15
|
+
const PORT_MIN = 1
|
|
16
|
+
const PORT_MAX = 65535
|
|
17
|
+
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,31}$/
|
|
18
|
+
const ENTRY_PATH = 'path'
|
|
19
|
+
const ENTRY_SUBDOMAIN = 'subdomain'
|
|
20
|
+
const ENTRY_LABELS = { path: '路径', subdomain: '子域名' }
|
|
21
|
+
const EMPTY_ROW = { name: '', targetPort: '', entry: ENTRY_PATH, wsText: '' }
|
|
22
|
+
|
|
23
|
+
// access 规范形态:子域名按当前 Host 派生 <name>.<当前域>, 泛解析域名场景自动成立
|
|
24
|
+
function accessUrlOf(row) {
|
|
25
|
+
const host = window.location.host
|
|
26
|
+
const target = row.entry === ENTRY_SUBDOMAIN
|
|
27
|
+
? row.name + '.' + host
|
|
28
|
+
: host + '/p/' + row.name
|
|
29
|
+
return window.location.protocol + '//' + target + '/'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatCreatedAt(iso) {
|
|
33
|
+
if (typeof iso !== 'string' || iso === '') return '-'
|
|
34
|
+
const parsed = new Date(iso)
|
|
35
|
+
return Number.isNaN(parsed.getTime()) ? '-' : parsed.toLocaleString()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function copyText(text) {
|
|
39
|
+
try {
|
|
40
|
+
await navigator.clipboard.writeText(text)
|
|
41
|
+
return true
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/* LOGIC-END */
|
|
47
|
+
|
|
48
|
+
/* SBUILD-BEGIN */
|
|
49
|
+
const STYLE = `
|
|
50
|
+
/* 组件令牌层(形制对齐 dsh-im / cron-board):宿主 dsw-alias 定义在 body 作用域, tu- 令牌必须在 body 声明才能解析到主题值;
|
|
51
|
+
fallback 取浅色中性, 深色主题由令牌接管, fallback 只兜裸宿主 */
|
|
52
|
+
body{
|
|
53
|
+
--tu-bg:var(--dsw-alias-bg-layer-1,#ffffff);
|
|
54
|
+
--tu-bg-sub:var(--dsw-alias-bg-layer-2,#f7f8fa);
|
|
55
|
+
--tu-bg-muted:var(--dsw-alias-bg-module-platform,#f2f3f5);
|
|
56
|
+
--tu-border:var(--dsw-alias-border-l2,#e5e6eb);
|
|
57
|
+
--tu-border-strong:var(--dsw-alias-border-l3,#dfe1e5);
|
|
58
|
+
--tu-text:var(--dsw-alias-label-primary,#1f2329);
|
|
59
|
+
--tu-text-sub:var(--dsw-alias-label-secondary,#646a73);
|
|
60
|
+
--tu-text-dim:var(--dsw-alias-label-tertiary,#8f959e);
|
|
61
|
+
--tu-accent:#1677ff;
|
|
62
|
+
--tu-accent-deep:#0958d9;
|
|
63
|
+
--tu-accent-wash:#eaf3ff;
|
|
64
|
+
--tu-danger:var(--dsw-alias-state-error-primary,#d54941);
|
|
65
|
+
--tu-ok:var(--dsw-alias-state-success-primary,#20a162);
|
|
66
|
+
--tu-hover:var(--dsw-alias-interactive-bg-hover,#f7f8fa);
|
|
67
|
+
--tu-mask:var(--dsw-alias-bg-mask-1,rgba(31,35,41,.5));
|
|
68
|
+
--tu-shadow:0 1px 2px rgb(31 35 41 / 4%);
|
|
69
|
+
--tu-radius-sm:6px;--tu-radius-md:8px;--tu-radius-lg:14px;
|
|
70
|
+
--tu-space-2:6px;--tu-space-3:8px;--tu-space-4:10px;--tu-space-5:12px;--tu-space-6:18px;
|
|
71
|
+
--tu-font-sm:12px;--tu-font-md:13px;--tu-font-lg:15px;
|
|
72
|
+
--tu-control-h:30px;
|
|
73
|
+
}
|
|
74
|
+
.tu-panel{display:flex;flex-direction:column;gap:var(--tu-space-5);min-width:0;max-width:880px;margin:0 auto;width:100%;color:var(--tu-text);container-type:inline-size}
|
|
75
|
+
.tu-main{flex:1;min-height:0;display:flex;flex-direction:column;overflow:auto;padding:var(--tu-space-6);background:var(--dsw-alias-bg-base,var(--tu-bg))}
|
|
76
|
+
.tu-toolbar{display:flex;align-items:center;gap:var(--tu-space-3);flex-wrap:wrap}
|
|
77
|
+
.tu-spacer{flex:1}
|
|
78
|
+
.tu-button{min-height:var(--tu-control-h);display:inline-flex;align-items:center;justify-content:center;gap:7px;border:1px solid var(--tu-border-strong);border-radius:var(--tu-radius-md);padding:0 13px;font:inherit;font-size:var(--tu-font-md);font-weight:560;background:var(--tu-bg);color:var(--tu-text);cursor:pointer;transition:border-color .15s,background .15s,color .15s}
|
|
79
|
+
.tu-button:hover:not(:disabled){border-color:#aeb3bb;background:var(--tu-hover)}
|
|
80
|
+
.tu-button:disabled{cursor:not-allowed;opacity:.55}
|
|
81
|
+
.tu-button--primary{background:var(--tu-accent);border-color:var(--tu-accent);color:#fff}
|
|
82
|
+
.tu-button--primary:hover:not(:disabled){background:var(--tu-accent-deep);border-color:var(--tu-accent-deep)}
|
|
83
|
+
.tu-button--danger{color:var(--tu-danger)}
|
|
84
|
+
.tu-button--danger:hover:not(:disabled){border-color:var(--tu-danger);background:var(--tu-danger);color:#fff}
|
|
85
|
+
.tu-button--sm{min-height:28px;padding:0 10px;font-size:var(--tu-font-sm)}
|
|
86
|
+
.tu-button:focus-visible,.tu-icon:focus-visible{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, white);outline-offset:2px}
|
|
87
|
+
.tu-meta{font-size:var(--tu-font-sm);color:var(--tu-text-sub)}
|
|
88
|
+
.tu-rows{display:flex;flex-direction:column;gap:var(--tu-space-1);font-size:var(--tu-font-md)}
|
|
89
|
+
.tu-row{display:flex;align-items:center;gap:var(--tu-space-3);border:1px solid var(--tu-border);border-radius:var(--tu-radius-md);padding:var(--tu-space-2) var(--tu-space-4);flex-wrap:wrap;transition:background .15s,border-color .15s}
|
|
90
|
+
.tu-row:hover{background:var(--tu-hover)}
|
|
91
|
+
.tu-name{font-size:14px;font-weight:600;color:var(--tu-text)}
|
|
92
|
+
.tu-badge{min-height:22px;display:inline-flex;align-items:center;padding:0 9px;border-radius:999px;color:var(--tu-text-sub);background:var(--tu-bg-muted);font-size:var(--tu-font-sm);white-space:nowrap}
|
|
93
|
+
.tu-code{font-family:ui-monospace,SFMono-Regular,monospace;font-size:var(--tu-font-sm);color:var(--tu-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
|
94
|
+
.tu-link{border:none;background:transparent;padding:0;font:inherit;cursor:pointer;color:var(--tu-accent);text-decoration:underline;text-underline-offset:3px}
|
|
95
|
+
.tu-link:hover{color:var(--tu-accent-deep)}
|
|
96
|
+
.tu-actions{display:flex;align-items:center;gap:var(--tu-space-2);margin-left:auto}
|
|
97
|
+
.tu-icon{min-height:28px;border:none;background:transparent;color:var(--tu-text-sub);cursor:pointer;font-size:var(--tu-font-sm);padding:0 8px;border-radius:var(--tu-radius-sm);transition:background .15s,color .15s}
|
|
98
|
+
.tu-icon:hover{background:var(--tu-hover);color:var(--tu-text)}
|
|
99
|
+
.tu-empty{font-size:var(--tu-font-md);color:var(--tu-text-dim);padding:var(--tu-space-6) 0;text-align:center}
|
|
100
|
+
.tu-error{font-size:var(--tu-font-sm);color:var(--tu-danger);background:var(--tu-bg-sub);border-left:3px solid var(--tu-danger);border-radius:var(--tu-radius-sm);padding:var(--tu-space-2) var(--tu-space-3)}
|
|
101
|
+
.tu-switch{position:relative;display:inline-flex;align-items:center;cursor:pointer;gap:var(--tu-space-2)}
|
|
102
|
+
.tu-switch input[type="checkbox"] { position:absolute;opacity:0;width:1px;height:1px }
|
|
103
|
+
.tu-switch__track{width:30px;height:17px;border-radius:999px;background:#aeb3bb;position:relative;transition:background .15s;flex:none}
|
|
104
|
+
.tu-switch__thumb{position:absolute;top:2px;left:2px;width:13px;height:13px;border-radius:50%;background:#fff;transition:left .15s;box-shadow:0 1px 2px rgba(0,0,0,.3)}
|
|
105
|
+
.tu-switch input[type="checkbox"]:checked + .tu-switch__track{background:var(--tu-accent)}
|
|
106
|
+
.tu-switch input[type="checkbox"]:checked + .tu-switch__track .tu-switch__thumb{left:15px}
|
|
107
|
+
.tu-switch input[type="checkbox"]:focus-visible + .tu-switch__track{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, white);outline-offset:1px}
|
|
108
|
+
.tu-switch input[type="checkbox"]:disabled + .tu-switch__track{opacity:.4;cursor:not-allowed}
|
|
109
|
+
.tu-switch:not(:has(input[type="checkbox"]:disabled)):hover .tu-switch__track{background:var(--tu-accent-deep);opacity:.85}
|
|
110
|
+
.tu-switch input[type="checkbox"]:checked:not(:disabled) + .tu-switch__track:hover{background:var(--tu-accent-deep)}
|
|
111
|
+
/* 设置>插件页卡片(tu-pc):镜像官方 PluginCard 形制, 数值与令牌直取官方 CSS Module;
|
|
112
|
+
官方壳未导出, slot 契约声明卡片外观归插件自持, 同页同 dsw-alias 令牌即同观感(含深色主题) */
|
|
113
|
+
.tu-pc{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-3);border-radius:16px;list-style:none;transition:border-color .16s,background .16s}
|
|
114
|
+
.tu-pc:hover{border-color:var(--dsw-alias-label-dimmed)}
|
|
115
|
+
.tu-pc--open{background:var(--dsw-alias-bg-layer-2);border-color:var(--dsw-alias-label-dimmed)}
|
|
116
|
+
.tu-pc__head{appearance:none;width:100%;font:inherit;color:inherit;text-align:left;cursor:pointer;background:0 0;border:0;border-radius:12px;align-items:center;gap:12px;padding:14px 16px;display:flex}
|
|
117
|
+
.tu-pc__head:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:-2px}
|
|
118
|
+
.tu-pc__text{flex-direction:column;flex:1;gap:4px;min-width:0;display:flex}
|
|
119
|
+
.tu-pc__name{color:var(--dsw-alias-label-primary);font-size:15px;font-weight:600;line-height:1.4}
|
|
120
|
+
.tu-pc__desc{color:var(--dsw-alias-label-tertiary);font-size:13px;line-height:1.5}
|
|
121
|
+
.tu-pc__chevron{color:var(--dsw-alias-label-tertiary);flex:none;display:inline-flex;transition:transform .16s}
|
|
122
|
+
.tu-pc--open .tu-pc__chevron{transform:rotate(180deg)}
|
|
123
|
+
.tu-pc__body{border-top:.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding-bottom:8px}
|
|
124
|
+
.tu-pc__row{gap:6px;padding:12px 0;display:grid}
|
|
125
|
+
.tu-pc__rowLine{color:var(--dsw-alias-label-primary);justify-content:space-between;align-items:flex-start;gap:16px;font-size:13px;line-height:1.5;display:flex}
|
|
126
|
+
.tu-pc__rowLabel{flex:1;min-width:0}
|
|
127
|
+
.tu-pc__hint{margin:0;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}
|
|
128
|
+
.tu-modal-mask{position:fixed;inset:0;background:var(--tu-mask);display:flex;align-items:center;justify-content:center;z-index:60;animation:tu-fade-in .16s ease-out}
|
|
129
|
+
.tu-modal{background:var(--tu-bg);color:var(--tu-text);border-radius:var(--tu-radius-lg);padding:var(--tu-space-6);max-width:520px;width:min(520px,94vw);max-height:90vh;overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 12px 40px rgb(31 35 41 / 18%);animation:tu-pop-in .2s ease-out}
|
|
130
|
+
@keyframes tu-fade-in{from{opacity:0}to{opacity:1}}
|
|
131
|
+
@keyframes tu-pop-in{from{opacity:0;transform:translateY(8px) scale(.98)}to{opacity:1;transform:none}}
|
|
132
|
+
@media (prefers-reduced-motion: reduce){.tu-modal-mask,.tu-modal{animation:none}.tu-row,.tu-button,.tu-icon,.tu-switch__track,.tu-pc,.tu-pc__chevron{transition:none}}
|
|
133
|
+
.tu-modal-head{display:flex;align-items:center;justify-content:space-between;gap:var(--tu-space-3);padding-bottom:var(--tu-space-2);border-bottom:1px solid var(--tu-border)}
|
|
134
|
+
.tu-modal-title{font-size:16px;font-weight:600}
|
|
135
|
+
.tu-modal-body{display:flex;flex-direction:column;gap:var(--tu-space-4)}
|
|
136
|
+
.tu-seg{display:inline-flex;gap:2px;background:var(--tu-bg-muted);border-radius:8px;padding:2px;width:fit-content}
|
|
137
|
+
.tu-seg__opt{border:none;background:transparent;border-radius:6px;padding:5px 11px;font:inherit;font-size:var(--tu-font-sm);font-weight:520;color:var(--tu-text-sub);cursor:pointer;line-height:1.2;white-space:nowrap;transition:background .15s,color .15s}
|
|
138
|
+
.tu-seg__opt:hover{color:var(--tu-text)}
|
|
139
|
+
.tu-seg__opt--on{background:var(--tu-accent);color:#fff;font-weight:560}
|
|
140
|
+
.tu-seg__opt--on:hover{color:#fff}
|
|
141
|
+
.tu-seg__opt:focus-visible{outline:2px solid color-mix(in srgb, var(--tu-accent) 70%, white);outline-offset:1px}
|
|
142
|
+
.tu-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--tu-space-3) var(--tu-space-4)}
|
|
143
|
+
.tu-field{display:flex;flex-direction:column;gap:4px;font-size:var(--tu-font-sm);min-width:0;color:var(--tu-text-sub)}
|
|
144
|
+
.tu-field--wide{grid-column:1 / -1}
|
|
145
|
+
.tu-field input[type="text"],.tu-field input[type="number"],.tu-field textarea{min-height:30px;border:1px solid var(--tu-border-strong);border-radius:var(--tu-radius-md);padding:4px 10px;font:inherit;font-size:var(--tu-font-md);background:var(--tu-bg);color:var(--tu-text);min-width:0;transition:border-color .15s,box-shadow .15s}
|
|
146
|
+
.tu-field input:focus,.tu-field textarea:focus{outline:none;border-color:var(--tu-accent);box-shadow:0 0 0 3px var(--tu-accent-wash)}
|
|
147
|
+
.tu-field textarea{min-height:52px;resize:vertical;font-family:inherit}
|
|
148
|
+
.tu-hint{font-size:var(--tu-font-sm);color:var(--tu-text-dim)}
|
|
149
|
+
@media (max-width: 560px){
|
|
150
|
+
.tu-grid{grid-template-columns:1fr}
|
|
151
|
+
.tu-modal{max-height:92vh}
|
|
152
|
+
.tu-main{padding:var(--tu-space-4)}
|
|
153
|
+
}
|
|
154
|
+
`
|
|
155
|
+
/* SBUILD-END */
|
|
156
|
+
|
|
157
|
+
// 空模块兜底:react 缺席即整面板禁用(宿主必有 react, 防御性)
|
|
158
|
+
function noopFactory() {
|
|
159
|
+
return { inject: [], apply() {} }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (typeof window !== 'undefined' && window.__ModuleLoader__) {
|
|
163
|
+
window.__ModuleLoader__.load({ id: '@mzzsfy/dsh-tunnel', factory })
|
|
164
|
+
|
|
165
|
+
// 导航图标声明:交给 dsh-settings-nav-icons 统一渲染;插件未就绪时入队, 由其启动时排空
|
|
166
|
+
const NAV_ICON = { 'dsh-tunnel': 'globe' }
|
|
167
|
+
if (window.__navicIcons !== undefined) window.__navicIcons.register(NAV_ICON)
|
|
168
|
+
else if (Array.isArray(window.__navicIconQueue)) window.__navicIconQueue.push(NAV_ICON)
|
|
169
|
+
else window.__navicIconQueue = [NAV_ICON]
|
|
170
|
+
|
|
171
|
+
function factory(require) {
|
|
172
|
+
let React = null
|
|
173
|
+
let ReactDOMClient = null
|
|
174
|
+
try {
|
|
175
|
+
React = require('react')
|
|
176
|
+
ReactDOMClient = require('react-dom/client')
|
|
177
|
+
} catch {
|
|
178
|
+
return noopFactory()
|
|
179
|
+
}
|
|
180
|
+
const { useState, useEffect, useRef, useCallback } = React
|
|
181
|
+
const h = (type, props, ...children) => React.createElement(type, props ?? null, ...children)
|
|
182
|
+
|
|
183
|
+
// 数据请求:统一 envelope, error 透传中文业务文案
|
|
184
|
+
async function request(method, endpoint, body) {
|
|
185
|
+
let response
|
|
186
|
+
try {
|
|
187
|
+
response = await fetch(API_PREFIX + endpoint, {
|
|
188
|
+
method,
|
|
189
|
+
headers: body === undefined ? undefined : { 'content-type': 'application/json' },
|
|
190
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
191
|
+
})
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return { ok: false, error: String(error && error.message || error) }
|
|
194
|
+
}
|
|
195
|
+
let json = null
|
|
196
|
+
try { json = await response.json() } catch { json = null }
|
|
197
|
+
if (!response.ok) return { ok: false, error: json && json.error ? json.error : '请求失败(' + response.status + ')' }
|
|
198
|
+
return { ok: true, data: json }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function ensureStyle(document) {
|
|
202
|
+
const existing = document.getElementById('dsh-tunnel-style')
|
|
203
|
+
if (existing) return
|
|
204
|
+
const style = document.createElement('style')
|
|
205
|
+
style.id = 'dsh-tunnel-style'
|
|
206
|
+
// 官方契约:插件自注样式必须自带 data-plugin, 否则任意后续插件材质化时
|
|
207
|
+
// claimStyles 会把它归属给该插件, 其 HMR 重建即整批误删本插件样式
|
|
208
|
+
style.setAttribute('data-plugin', '@mzzsfy/dsh-tunnel')
|
|
209
|
+
style.textContent = STYLE
|
|
210
|
+
document.head.appendChild(style)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// 规约 switch:input 锚定状态选择器, 视觉由 track+thumb 呈现
|
|
214
|
+
function switchToggle(props) {
|
|
215
|
+
return h('label', { className: 'tu-switch' },
|
|
216
|
+
h('input', { type: 'checkbox', checked: props.checked, disabled: props.disabled, onChange: props.onChange,
|
|
217
|
+
'aria-label': props.ariaLabel }),
|
|
218
|
+
h('span', { className: 'tu-switch__track' }, h('span', { className: 'tu-switch__thumb' })),
|
|
219
|
+
props.label ? h('span', { className: 'tu-meta' }, props.label) : null)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function Modal({ title, onClose, children }) {
|
|
223
|
+
const bodyRef = useRef(null)
|
|
224
|
+
useEffect(() => {
|
|
225
|
+
const onKey = (event) => { if (event.key === 'Escape') onClose() }
|
|
226
|
+
document.addEventListener('keydown', onKey)
|
|
227
|
+
const first = bodyRef.current && bodyRef.current.querySelector('input, textarea')
|
|
228
|
+
if (first) first.focus()
|
|
229
|
+
return () => document.removeEventListener('keydown', onKey)
|
|
230
|
+
}, [])
|
|
231
|
+
return h('div', { className: 'tu-modal-mask', onClick: (event) => { if (event.target === event.currentTarget) onClose() } },
|
|
232
|
+
h('div', { className: 'tu-modal' },
|
|
233
|
+
h('div', { className: 'tu-modal-head' },
|
|
234
|
+
h('span', { className: 'tu-modal-title' }, title),
|
|
235
|
+
h('button', { className: 'tu-icon', 'aria-label': '关闭', onClick: onClose }, '✕')),
|
|
236
|
+
h('div', { className: 'tu-modal-body', ref: bodyRef }, children)))
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function PillGroup({ options, value, onChange }) {
|
|
240
|
+
return h('div', { className: 'tu-seg' },
|
|
241
|
+
options.map((option) => h('button', {
|
|
242
|
+
key: option.value,
|
|
243
|
+
type: 'button',
|
|
244
|
+
className: 'tu-seg__opt' + (option.value === value ? ' tu-seg__opt--on' : ''),
|
|
245
|
+
onClick: () => onChange(option.value),
|
|
246
|
+
}, option.label)))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// —— 隧道列表面板 ——
|
|
250
|
+
function TunnelRow({ row, onRemove, busy }) {
|
|
251
|
+
const [copied, setCopied] = useState(false)
|
|
252
|
+
const url = accessUrlOf(row)
|
|
253
|
+
const copy = async () => {
|
|
254
|
+
if (await copyText(url)) {
|
|
255
|
+
setCopied(true)
|
|
256
|
+
setTimeout(() => setCopied(false), COPY_FLASH_MS)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return h('div', { className: 'tu-row' },
|
|
260
|
+
h('span', { className: 'tu-name' }, row.name),
|
|
261
|
+
h('span', { className: 'tu-badge' }, ENTRY_LABELS[row.entry] ?? row.entry),
|
|
262
|
+
h('span', { className: 'tu-code' }, row.entry === ENTRY_SUBDOMAIN ? row.name + '.*' : '/p/' + row.name),
|
|
263
|
+
h('button', { type: 'button', className: 'tu-link', title: '点击复制 ' + url, onClick: copy },
|
|
264
|
+
copied ? '已复制' : '复制地址'),
|
|
265
|
+
h('span', { className: 'tu-code' }, ':' + row.targetPort),
|
|
266
|
+
row.wsPaths && row.wsPaths.length > 0 ? h('span', { className: 'tu-code' }, 'ws ' + row.wsPaths.join(' ')) : null,
|
|
267
|
+
h('span', { className: 'tu-meta' }, formatCreatedAt(row.createdAt)),
|
|
268
|
+
h('span', { className: 'tu-actions' },
|
|
269
|
+
h('button', { type: 'button', className: 'tu-button tu-button--sm tu-button--danger',
|
|
270
|
+
disabled: busy, onClick: () => onRemove(row) }, '删除')))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function AddTunnelModal({ onClose, onAdded }) {
|
|
274
|
+
const [draft, setDraft] = useState(EMPTY_ROW)
|
|
275
|
+
const [error, setError] = useState('')
|
|
276
|
+
const [busy, setBusy] = useState(false)
|
|
277
|
+
const patch = (part) => setDraft((prev) => ({ ...prev, ...part }))
|
|
278
|
+
const submit = async () => {
|
|
279
|
+
setBusy(true)
|
|
280
|
+
setError('')
|
|
281
|
+
const wsPaths = draft.wsText.split(/\r?\n/).map((line) => line.trim()).filter((line) => line !== '')
|
|
282
|
+
const body = { name: draft.name.trim(), targetPort: Number(draft.targetPort), entry: draft.entry }
|
|
283
|
+
if (draft.entry === ENTRY_PATH && wsPaths.length > 0) body.wsPaths = wsPaths
|
|
284
|
+
const outcome = await request('POST', '/tunnels', body)
|
|
285
|
+
setBusy(false)
|
|
286
|
+
if (!outcome.ok) { setError(outcome.error); return }
|
|
287
|
+
onAdded()
|
|
288
|
+
}
|
|
289
|
+
const nameOk = NAME_RE.test(draft.name.trim())
|
|
290
|
+
const portValue = Number(draft.targetPort)
|
|
291
|
+
const portOk = Number.isInteger(portValue) && portValue >= PORT_MIN && portValue <= PORT_MAX
|
|
292
|
+
return h(Modal, { title: '添加隧道', onClose },
|
|
293
|
+
h('div', { className: 'tu-grid' },
|
|
294
|
+
h('div', { className: 'tu-field' },
|
|
295
|
+
h('span', null, '隧道名'),
|
|
296
|
+
h('input', { type: 'text', value: draft.name, placeholder: '小写字母/数字/连字符, ≤32 位',
|
|
297
|
+
onChange: (event) => patch({ name: event.target.value }) }),
|
|
298
|
+
h('span', { className: 'tu-hint' }, '避开 dsh 入口域名首标签(如 dsh); 纯 IP 访问时避开 IP 首段数字(如 127)')),
|
|
299
|
+
h('div', { className: 'tu-field' },
|
|
300
|
+
h('span', null, '目标端口'),
|
|
301
|
+
h('input', { type: 'number', value: draft.targetPort, placeholder: PORT_MIN + '..' + PORT_MAX,
|
|
302
|
+
onChange: (event) => patch({ targetPort: event.target.value }) }),
|
|
303
|
+
h('span', { className: 'tu-hint' }, '本机应用监听端口')),
|
|
304
|
+
h('div', { className: 'tu-field tu-field--wide' },
|
|
305
|
+
h('span', null, '入口形态'),
|
|
306
|
+
h(PillGroup, {
|
|
307
|
+
options: [{ label: ENTRY_LABELS.path + ' (/p/<名>)', value: ENTRY_PATH },
|
|
308
|
+
{ label: ENTRY_LABELS.subdomain + ' (<名>.*)', value: ENTRY_SUBDOMAIN }],
|
|
309
|
+
value: draft.entry,
|
|
310
|
+
onChange: (entry) => patch({ entry }),
|
|
311
|
+
}),
|
|
312
|
+
h('span', { className: 'tu-hint' }, draft.entry === ENTRY_SUBDOMAIN
|
|
313
|
+
? '全路径透传, WebSocket 任意路径可用, 需泛解析域名或 <名>.localhost 访问'
|
|
314
|
+
: '子路径部署, 目标应用需支持 base 参数(vite --base / jupyter base_url 等)')),
|
|
315
|
+
draft.entry === ENTRY_PATH ? h('div', { className: 'tu-field tu-field--wide' },
|
|
316
|
+
h('span', null, 'WebSocket 路径(可选, 每行一条)'),
|
|
317
|
+
h('textarea', { value: draft.wsText, placeholder: '/\n/ws',
|
|
318
|
+
onChange: (event) => patch({ wsText: event.target.value }) }),
|
|
319
|
+
h('span', { className: 'tu-hint' }, '留空 = 纯 HTTP; 子域名模式不需要')) : null),
|
|
320
|
+
error ? h('div', { className: 'tu-error' }, error) : null,
|
|
321
|
+
h('div', { className: 'tu-actions' },
|
|
322
|
+
h('button', { type: 'button', className: 'tu-button', onClick: onClose }, '取消'),
|
|
323
|
+
h('button', { type: 'button', className: 'tu-button tu-button--primary',
|
|
324
|
+
disabled: busy || !nameOk || !portOk, onClick: submit }, '创建')))
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function TunnelPanel() {
|
|
328
|
+
const [rows, setRows] = useState(null)
|
|
329
|
+
const [error, setError] = useState('')
|
|
330
|
+
const [showAdd, setShowAdd] = useState(false)
|
|
331
|
+
const [busyName, setBusyName] = useState('')
|
|
332
|
+
const [reloadFlag, setReloadFlag] = useState(0)
|
|
333
|
+
const reload = useCallback(() => setReloadFlag((flag) => flag + 1), [])
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
let alive = true
|
|
336
|
+
const load = async () => {
|
|
337
|
+
const listOutcome = await request('GET', '/tunnels')
|
|
338
|
+
if (!alive) return
|
|
339
|
+
// 成功即清错误: 瞬态失败(如插件重载期轮询)恢复后错误条不滞留
|
|
340
|
+
if (listOutcome.ok) { setRows(listOutcome.data.items); setError('') }
|
|
341
|
+
else setError(listOutcome.error)
|
|
342
|
+
}
|
|
343
|
+
load()
|
|
344
|
+
const timer = setInterval(load, REFRESH_INTERVAL_MS)
|
|
345
|
+
return () => { alive = false; clearInterval(timer) }
|
|
346
|
+
}, [reloadFlag])
|
|
347
|
+
const remove = async (row) => {
|
|
348
|
+
if (!window.confirm('删除隧道 ' + row.name + '?')) return
|
|
349
|
+
setBusyName(row.name)
|
|
350
|
+
const outcome = await request('DELETE', '/tunnels/' + encodeURIComponent(row.name))
|
|
351
|
+
setBusyName('')
|
|
352
|
+
if (!outcome.ok) setError(outcome.error)
|
|
353
|
+
else reload()
|
|
354
|
+
}
|
|
355
|
+
return h('div', { className: 'tu-panel' },
|
|
356
|
+
h('div', { className: 'tu-toolbar' },
|
|
357
|
+
h('span', { className: 'tu-meta' }, rows ? '共 ' + rows.length + ' 条隧道' : '加载中…'),
|
|
358
|
+
h('span', { className: 'tu-spacer' }),
|
|
359
|
+
h('button', { type: 'button', className: 'tu-button tu-button--primary', onClick: () => setShowAdd(true) }, '添加隧道')),
|
|
360
|
+
error ? h('div', { className: 'tu-error' }, error) : null,
|
|
361
|
+
rows && rows.length === 0 ? h('div', { className: 'tu-empty' }, '暂无隧道, 点击右上角「添加隧道」创建') : null,
|
|
362
|
+
rows ? h('div', { className: 'tu-rows' },
|
|
363
|
+
rows.map((row) => h(TunnelRow, { key: row.name, row, busy: busyName === row.name, onRemove: remove }))) : null,
|
|
364
|
+
showAdd ? h(AddTunnelModal, { onClose: () => setShowAdd(false), onAdded: () => { setShowAdd(false); reload() } }) : null)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// —— 挂载:全局面板(main keyed + sidebar.panellist)与 better-sidebar tab 形态仲裁 ——
|
|
368
|
+
// 侧栏行与中央面板均由宿主渲染, 禁 DOM 刮取;仲裁仅由偏好驱动(设计 §3.2)
|
|
369
|
+
|
|
370
|
+
const CARD_TITLE = '穿透隧道'
|
|
371
|
+
const CARD_DESC = '手动管理穿透隧道: 路径/子域名双入口, 列表与增删, ai 使用开关'
|
|
372
|
+
const CARD_TOGGLE_LABEL = '看板移入 better-sidebar 侧边栏'
|
|
373
|
+
const CARD_AI_LABEL = '允许 ai 使用隧道工具'
|
|
374
|
+
const CARD_AI_HINT = '关闭后 ai 无法调用 tunnel_open/tunnel_list/tunnel_close, 看板与 REST 管理不受影响。'
|
|
375
|
+
const CHEVRON_DOWN = () => h('svg', { viewBox: '0 0 14 14', width: 14, height: 14, fill: 'none',
|
|
376
|
+
stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
|
|
377
|
+
h('path', { d: 'm3.5 5.5 3.5 3.5 3.5-3.5' }))
|
|
378
|
+
|
|
379
|
+
// 面板图标:panellist 渲染契约组件与侧边栏 tab 图标共用, 宿主传 { size, active }
|
|
380
|
+
function PanelIcon({ size }) {
|
|
381
|
+
return h('svg', { viewBox: '0 0 16 16', width: size, height: size, fill: 'none',
|
|
382
|
+
stroke: 'currentColor', strokeWidth: 1.3, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true' },
|
|
383
|
+
h('circle', { cx: 7, cy: 7, r: 4.5 }),
|
|
384
|
+
h('path', { d: 'm10.5 10.5 3 3' }))
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function registerMainPanel(ctx) {
|
|
388
|
+
return ctx.effect(() => {
|
|
389
|
+
const injectDisposers = []
|
|
390
|
+
injectDisposers.push(ctx.slots.inject('main', () => ctx.slots.register({
|
|
391
|
+
name: 'main',
|
|
392
|
+
key: PANEL_ID,
|
|
393
|
+
}, () => h('div', { className: 'tu-main' }, h(TunnelPanel)))))
|
|
394
|
+
injectDisposers.push(ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
395
|
+
name: 'sidebar.panellist',
|
|
396
|
+
id: PANEL_ID,
|
|
397
|
+
label: PANEL_LABEL,
|
|
398
|
+
}, (iconProps) => h(PanelIcon, iconProps))))
|
|
399
|
+
return () => { for (const dispose of injectDisposers) dispose() }
|
|
400
|
+
}, 'dsh-tunnel main panel')
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// better-sidebar tab:注册即单实例(single);只注册不主动打开,
|
|
404
|
+
// 打开由用户从侧边栏目录手动进入
|
|
405
|
+
function registerBoardTab(ctx, service) {
|
|
406
|
+
return ctx.effect(() => {
|
|
407
|
+
return service.registerTab({
|
|
408
|
+
id: TAB_ID,
|
|
409
|
+
title: PANEL_LABEL,
|
|
410
|
+
icon: (size) => h(PanelIcon, { size }),
|
|
411
|
+
order: TAB_ORDER,
|
|
412
|
+
single: true,
|
|
413
|
+
component: (props) => h('div', { className: 'tu-main' }, h(TunnelPanel)),
|
|
414
|
+
})
|
|
415
|
+
}, 'dsh-tunnel sidebar tab')
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// 设置>插件页卡片:官方 PluginCard 形制;better-sidebar 在场可切换, 否则仅展示禁用态
|
|
419
|
+
function TunnelPluginCard({ ctx }) {
|
|
420
|
+
const [sidebarOn, setSidebarOn] = useState(null)
|
|
421
|
+
const [aiOn, setAiOn] = useState(null)
|
|
422
|
+
const [busyKey, setBusyKey] = useState('')
|
|
423
|
+
const [open, setOpen] = useState(false)
|
|
424
|
+
// 渲染时重探服务在场:better-sidebar 晚于设置页装载时, 重渲染自动纠正禁用态
|
|
425
|
+
const sidebarReady = ctx.get('betterSidebar') !== undefined
|
|
426
|
+
useEffect(() => {
|
|
427
|
+
let alive = true
|
|
428
|
+
request('GET', '/status').then((outcome) => {
|
|
429
|
+
if (!alive || !outcome.ok || !outcome.data || !outcome.data.ui) return
|
|
430
|
+
setSidebarOn(outcome.data.ui.sidebarTab === true)
|
|
431
|
+
setAiOn(outcome.data.ui.aiTools !== false)
|
|
432
|
+
})
|
|
433
|
+
return () => { alive = false }
|
|
434
|
+
}, [])
|
|
435
|
+
const flip = async (key, current) => {
|
|
436
|
+
if (busyKey !== '' || current === null) return
|
|
437
|
+
if (key === 'sidebarTab' && !sidebarReady) return
|
|
438
|
+
setBusyKey(key)
|
|
439
|
+
const outcome = await request('POST', '/ui-settings', { [key]: !current })
|
|
440
|
+
if (outcome.ok && outcome.data && outcome.data.ui) {
|
|
441
|
+
setSidebarOn(outcome.data.ui.sidebarTab === true)
|
|
442
|
+
setAiOn(outcome.data.ui.aiTools !== false)
|
|
443
|
+
} else {
|
|
444
|
+
console.warn('[dsh-tunnel] 偏好写入失败: ' + ((outcome.data && outcome.data.error) || outcome.error || '未知'))
|
|
445
|
+
}
|
|
446
|
+
setBusyKey('')
|
|
447
|
+
}
|
|
448
|
+
return h('li', { className: 'tu-pc' + (open ? ' tu-pc--open' : '') },
|
|
449
|
+
h('button', { type: 'button', className: 'tu-pc__head', 'aria-expanded': open,
|
|
450
|
+
'aria-label': (open ? '折叠' : '展开') + ': ' + CARD_TITLE, onClick: () => setOpen(!open) },
|
|
451
|
+
h('span', { className: 'tu-pc__text' },
|
|
452
|
+
h('span', { className: 'tu-pc__name' }, CARD_TITLE),
|
|
453
|
+
h('span', { className: 'tu-pc__desc' }, CARD_DESC)),
|
|
454
|
+
h('span', { className: 'tu-pc__chevron' }, h(CHEVRON_DOWN))),
|
|
455
|
+
open ? h('div', { className: 'tu-pc__body' },
|
|
456
|
+
h('div', { className: 'tu-pc__row' },
|
|
457
|
+
h('div', { className: 'tu-pc__rowLine' },
|
|
458
|
+
h('span', { className: 'tu-pc__rowLabel' }, CARD_AI_LABEL),
|
|
459
|
+
switchToggle({
|
|
460
|
+
checked: aiOn === true,
|
|
461
|
+
disabled: busyKey !== '' || aiOn === null,
|
|
462
|
+
onChange: () => flip('aiTools', aiOn),
|
|
463
|
+
ariaLabel: CARD_AI_LABEL,
|
|
464
|
+
})),
|
|
465
|
+
h('p', { className: 'tu-pc__hint' }, CARD_AI_HINT)),
|
|
466
|
+
h('div', { className: 'tu-pc__row' },
|
|
467
|
+
h('div', { className: 'tu-pc__rowLine' },
|
|
468
|
+
h('span', { className: 'tu-pc__rowLabel' }, CARD_TOGGLE_LABEL),
|
|
469
|
+
switchToggle({
|
|
470
|
+
checked: sidebarOn === true,
|
|
471
|
+
disabled: busyKey !== '' || sidebarOn === null || !sidebarReady,
|
|
472
|
+
onChange: () => flip('sidebarTab', sidebarOn),
|
|
473
|
+
ariaLabel: CARD_TOGGLE_LABEL,
|
|
474
|
+
})),
|
|
475
|
+
h('p', { className: 'tu-pc__hint' },
|
|
476
|
+
sidebarReady ? '开启后看板以侧边栏页签呈现,关闭时使用全局面板。' : '需已装载 better-sidebar 后方可切换;未装载时看板始终使用全局面板。'))) : null)
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
// slots 供面板与设置卡片挂载(缺失则 fiber 未激活, 即干净禁用)
|
|
481
|
+
inject: ['slots'],
|
|
482
|
+
apply(ctx) {
|
|
483
|
+
ensureStyle(document)
|
|
484
|
+
// 挂载权交给用户:默认注册全局面板;偏好开启且侧边栏服务在场才移入扩展槽
|
|
485
|
+
let currentSidebar = ctx.get('betterSidebar')
|
|
486
|
+
let sidebarTabOn = false
|
|
487
|
+
let tabDisposer = null
|
|
488
|
+
let panelDisposer = null
|
|
489
|
+
const attachBoardTab = (sidebar) => registerBoardTab(ctx, sidebar)
|
|
490
|
+
const applyPref = () => {
|
|
491
|
+
const wantTab = sidebarTabOn && currentSidebar !== undefined
|
|
492
|
+
if (wantTab && tabDisposer === null) {
|
|
493
|
+
if (panelDisposer) { panelDisposer(); panelDisposer = null }
|
|
494
|
+
tabDisposer = attachBoardTab(currentSidebar)
|
|
495
|
+
} else if (!wantTab && tabDisposer !== null) {
|
|
496
|
+
tabDisposer()
|
|
497
|
+
tabDisposer = null
|
|
498
|
+
}
|
|
499
|
+
if (!wantTab && panelDisposer === null) panelDisposer = registerMainPanel(ctx)
|
|
500
|
+
}
|
|
501
|
+
const prefWatch = setInterval(async () => {
|
|
502
|
+
try {
|
|
503
|
+
const outcome = await request('GET', '/status')
|
|
504
|
+
const next = outcome.ok && outcome.data && outcome.data.ui ? outcome.data.ui.sidebarTab === true : false
|
|
505
|
+
if (next !== sidebarTabOn) {
|
|
506
|
+
const previous = sidebarTabOn
|
|
507
|
+
sidebarTabOn = next
|
|
508
|
+
try {
|
|
509
|
+
applyPref()
|
|
510
|
+
} catch (error) {
|
|
511
|
+
// 形态切换失败回滚偏好, 下轮轮询重试, 避免入口消失后卡死
|
|
512
|
+
sidebarTabOn = previous
|
|
513
|
+
console.warn('[dsh-tunnel] 看板形态切换失败:', error)
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
} catch { /* 轮询失败保形态不变, 下轮重试 */ }
|
|
517
|
+
}, PREF_POLL_MS)
|
|
518
|
+
ctx.effect(() => () => {
|
|
519
|
+
clearInterval(prefWatch)
|
|
520
|
+
if (tabDisposer) tabDisposer()
|
|
521
|
+
if (panelDisposer) panelDisposer()
|
|
522
|
+
}, 'dsh-tunnel mount arbitration')
|
|
523
|
+
// 设置>插件页卡片:key 配对 ns, 宿主按 describe 命名空间分发;effect 随 fiber 回收。
|
|
524
|
+
// 0.1.7-rc.1+ 该槽被宿主移除, 卡片换形注册进插件管理页包详情配置槽(plugins.bundle.config,
|
|
525
|
+
// key = 包名);两代槽位并存注册:inject 是声明生命周期效应, 错误世代宿主永不声明该槽,
|
|
526
|
+
// 回调挂起不执行, 零成本
|
|
527
|
+
ctx.effect(() => ctx.slots.inject('settings.plugin.item', function* () {
|
|
528
|
+
yield ctx.slots.register(
|
|
529
|
+
{ name: 'settings.plugin.item', key: PANEL_ID, label: PANEL_LABEL },
|
|
530
|
+
() => h(TunnelPluginCard, { ctx }),
|
|
531
|
+
)
|
|
532
|
+
}), 'dsh-tunnel settings card')
|
|
533
|
+
ctx.effect(() => ctx.slots.inject('plugins.bundle.config', function* () {
|
|
534
|
+
yield ctx.slots.register(
|
|
535
|
+
{ name: 'plugins.bundle.config', key: '@mzzsfy/dsh-tunnel' },
|
|
536
|
+
() => h(TunnelPluginCard, { ctx }),
|
|
537
|
+
)
|
|
538
|
+
}), 'dsh-tunnel plugin manager card')
|
|
539
|
+
ctx.inject(['betterSidebar'], (bsCtx) => {
|
|
540
|
+
currentSidebar = bsCtx.betterSidebar
|
|
541
|
+
applyPref()
|
|
542
|
+
})
|
|
543
|
+
applyPref()
|
|
544
|
+
},
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
})()
|