dsh-plugin-mobile-gateway 0.8.1 → 0.10.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/lib/client.js CHANGED
@@ -20,46 +20,55 @@ window.__ModuleLoader__.load({
20
20
  }
21
21
  const getOpen = () => open
22
22
 
23
+ // Shared Web UI / Desktop semantic tokens update with the host theme.
24
+ const restartNoticeStorageKey = 'mobile-gateway-dismissed-restart-notice'
25
+ let dismissedRestartInstance = null
26
+ try { dismissedRestartInstance = window.localStorage.getItem(restartNoticeStorageKey) } catch { /* storage unavailable */ }
27
+
23
28
  const colors = {
24
- panel: 'var(--color-bg, #111318)',
25
- card: 'var(--color-bg-elevated, rgba(127,127,127,.08))',
26
- text: 'var(--color-text, #f4f4f5)',
27
- muted: 'var(--color-text-muted, #9297a2)',
28
- border: 'var(--color-border, rgba(127,127,127,.24))',
29
- accent: 'var(--color-primary, #4f7cff)',
30
- danger: '#dc4c64',
31
- success: '#2fb171',
29
+ panel: 'var(--dsw-alias-bg-layer-2, var(--color-bg, Canvas))',
30
+ card: 'var(--dsw-alias-bg-module-platform, var(--color-bg-elevated, Canvas))',
31
+ text: 'var(--dsw-alias-label-primary, var(--color-text, CanvasText))',
32
+ muted: 'var(--dsw-alias-label-secondary, var(--color-text-muted, GrayText))',
33
+ border: 'var(--dsw-alias-border-l3, var(--color-border, rgba(127,127,127,.24)))',
34
+ accent: 'var(--dsw-alias-button-primary-fill, var(--color-primary, #4f7cff))',
35
+ accentText: 'var(--dsw-alias-label-primary-foreground, #fff)',
36
+ danger: 'var(--dsw-alias-state-error-primary, #dc4c64)',
37
+ success: 'var(--dsw-alias-state-success-primary, #2fb171)',
32
38
  }
33
39
  const styles = {
34
40
  backdrop: {
35
41
  position: 'fixed', inset: 0, zIndex: 1200, pointerEvents: 'auto',
36
- background: 'rgba(0,0,0,.32)', display: 'flex', justifyContent: 'flex-end',
42
+ background: 'var(--dsw-alias-bg-mask-1, rgba(0,0,0,.32))', display: 'flex',
43
+ justifyContent: 'center', alignItems: 'center',
44
+ padding: 'max(16px, var(--dsh-frame-overlay-top, 16px)) 16px', boxSizing: 'border-box',
37
45
  },
38
46
  panel: {
39
- width: 380, maxWidth: 'calc(100vw - 24px)', height: '100%', overflowY: 'auto',
47
+ width: 'clamp(640px, 84vw, 1120px)', maxWidth: '100%', maxHeight: '100%', minHeight: 0, height: 'min(780px, calc(100dvh - 48px))', overflow: 'hidden',
48
+ scrollbarWidth: 'none', msOverflowStyle: 'none',
40
49
  display: 'flex', flexDirection: 'column',
41
- background: colors.panel, color: colors.text, borderLeft: `1px solid ${colors.border}`,
42
- boxShadow: '-12px 0 40px rgba(0,0,0,.22)', padding: 20,
43
- fontFamily: 'system-ui, sans-serif', fontSize: 13, boxSizing: 'border-box',
50
+ background: colors.panel, color: colors.text, border: `1px solid ${colors.border}`, borderRadius: 'var(--dsw-radius-panel, 16px)',
51
+ boxShadow: 'var(--dsw-elevation-prominent, 0 20px 60px rgba(0,0,0,.22))', padding: 24,
52
+ fontFamily: 'var(--dsw-font-family, system-ui, sans-serif)', fontSize: 13, boxSizing: 'border-box',
44
53
  },
45
54
  header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18, flexShrink: 0 },
46
- title: { margin: 0, fontSize: 18, fontWeight: 650 },
47
- card: { background: colors.card, border: `1px solid ${colors.border}`, borderRadius: 12, padding: 14, marginBottom: 12, flexShrink: 0 },
55
+ title: { margin: 0, fontSize: 22, fontWeight: 650 },
56
+ card: { background: 'transparent', border: 0, borderBottom: `1px solid ${colors.border}`, borderRadius: 0, padding: '16px 0', marginBottom: 0, flexShrink: 0 },
48
57
  label: { display: 'block', color: colors.muted, fontSize: 12, marginBottom: 6 },
49
- input: { width: '100%', boxSizing: 'border-box', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '8px 10px', marginBottom: 10, background: 'transparent', color: 'inherit', outline: 'none' },
58
+ input: { width: '100%', boxSizing: 'border-box', border: `1px solid ${colors.border}`, borderRadius: 10, padding: '8px 12px', marginBottom: 8, background: colors.card, color: 'inherit', outline: 'none' },
50
59
  pairingText: { width: '100%', minHeight: 86, boxSizing: 'border-box', resize: 'vertical', border: `1px solid ${colors.border}`, borderRadius: 8, padding: '9px 10px', margin: '10px 0', background: 'transparent', color: 'inherit', outline: 'none', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 11, lineHeight: 1.45, overflowWrap: 'anywhere' },
51
60
  button: { cursor: 'pointer', border: `1px solid ${colors.border}`, background: 'transparent', borderRadius: 8, padding: '7px 11px', color: 'inherit', font: 'inherit' },
52
- headerButton: { width: 34, height: 34, boxSizing: 'border-box', padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
53
- primary: { background: colors.accent, color: '#fff', borderColor: colors.accent, width: '100%' },
54
- muted: { color: colors.muted, fontSize: 12, lineHeight: 1.55 },
61
+ headerButton: { width: 34, height: 34, boxSizing: 'border-box', border: 0, padding: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 },
62
+ primary: { background: colors.accent, color: colors.accentText, borderColor: colors.accent, width: '100%' },
63
+ muted: { color: colors.muted, fontSize: 12, lineHeight: 1.45 },
55
64
  row: { display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', columnGap: 12, alignItems: 'center', padding: '12px 0', borderBottom: `1px solid ${colors.border}` },
56
65
  badge: { display: 'inline-flex', alignItems: 'center', gap: 5, fontSize: 11, marginLeft: 8, color: colors.muted },
57
66
  dot: { width: 7, height: 7, borderRadius: 99, display: 'inline-block' },
58
- error: { color: '#ff8092', background: 'rgba(220,76,100,.12)', borderRadius: 8, padding: 10, marginBottom: 12 },
67
+ error: { color: colors.danger, background: 'rgba(220,76,100,.12)', borderRadius: 8, padding: 10, marginBottom: 12 },
59
68
  qr: { display: 'block', width: 220, height: 220, margin: '12px auto', background: '#fff', borderRadius: 10, padding: 8 },
60
- helpButton: { width: 20, height: 20, boxSizing: 'border-box', padding: 0, borderRadius: '50%', border: `1px solid ${colors.muted}`, background: 'transparent', color: colors.muted, cursor: 'help', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 12, fontWeight: 600, lineHeight: 1 },
61
- helpBubble: { position: 'absolute', top: '100%', left: 0, zIndex: 20, width: 'min(300px, calc(100vw - 88px))', maxHeight: 'min(440px, calc(100vh - 160px))', overflowY: 'auto', boxSizing: 'border-box', padding: 12, borderRadius: 10, border: '1px solid rgba(255,255,255,.18)', background: '#20242c', color: '#f4f4f5', boxShadow: '0 12px 28px rgba(0,0,0,.35)', fontSize: 12, lineHeight: 1.5 },
62
- toolsMenu: { position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 30, width: 250, maxWidth: 'calc(100vw - 48px)', border: `1px solid ${colors.border}`, borderRadius: 10, padding: 5, background: '#20242c', boxShadow: '0 12px 28px rgba(0,0,0,.35)' },
69
+ helpButton: { width: 20, height: 20, boxSizing: 'border-box', padding: 0, borderRadius: '50%', border: 0, background: 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.1))', color: 'var(--dsw-alias-label-tertiary, #888)', cursor: 'help', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 12, fontWeight: 600, lineHeight: 1 },
70
+ helpBubble: { position: 'absolute', top: '100%', left: 0, zIndex: 20, width: 'min(300px, calc(100vw - 88px))', maxHeight: 'min(440px, calc(100vh - 160px))', overflowY: 'auto', boxSizing: 'border-box', padding: 12, borderRadius: 10, border: `1px solid ${colors.border}`, background: colors.panel, color: colors.text, boxShadow: '0 12px 28px rgba(0,0,0,.35)', fontSize: 12, lineHeight: 1.5 },
71
+ toolsMenu: { position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 30, width: 250, maxWidth: 'calc(100vw - 48px)', border: `1px solid ${colors.border}`, borderRadius: 10, padding: 5, background: colors.panel, boxShadow: '0 12px 28px rgba(0,0,0,.35)' },
63
72
  toolsItem: { display: 'block', width: '100%', padding: '9px 10px', textAlign: 'left', border: 0, borderRadius: 7, background: 'transparent', color: colors.text, font: 'inherit', cursor: 'pointer' },
64
73
  switch: { position: 'relative', display: 'inline-flex', width: 42, height: 24, flexShrink: 0 },
65
74
  switchInput: { position: 'absolute', opacity: 0, width: 1, height: 1 },
@@ -68,6 +77,154 @@ window.__ModuleLoader__.load({
68
77
  version: { flexShrink: 0, marginTop: 'auto', padding: '18px 2px 0', color: colors.muted, fontSize: 11, lineHeight: 1.4, textAlign: 'right', letterSpacing: '.02em' },
69
78
  }
70
79
 
80
+ const panelCss = `
81
+ @keyframes mgw-pairing-enter {
82
+ from { opacity: 0; transform: scale(.82); }
83
+ to { opacity: 1; transform: scale(1); }
84
+ }
85
+ .mgw-pairing-dialog {
86
+ transform-origin: center;
87
+ animation: mgw-pairing-enter 240ms cubic-bezier(.2, .8, .2, 1);
88
+ }
89
+ @keyframes mgw-pairing-exit {
90
+ from { opacity: 1; transform: scale(1); }
91
+ to { opacity: 0; transform: scale(.82); }
92
+ }
93
+ .mgw-pairing-dialog[data-closing="true"] {
94
+ animation: mgw-pairing-exit 180ms ease-in forwards;
95
+ pointer-events: none;
96
+ }
97
+ @media (prefers-reduced-motion: reduce) {
98
+ .mgw-pairing-dialog { animation: none; }
99
+ }
100
+ .mobile-gateway-panel, .mobile-gateway-panel *, .mgw-pairing-dialog, .mgw-pairing-dialog *, .mgw-select-menu {
101
+ scrollbar-width: none !important;
102
+ -ms-overflow-style: none;
103
+ }
104
+ .mobile-gateway-panel::-webkit-scrollbar, .mobile-gateway-panel *::-webkit-scrollbar,
105
+ .mgw-pairing-dialog::-webkit-scrollbar, .mgw-pairing-dialog *::-webkit-scrollbar, .mgw-select-menu::-webkit-scrollbar {
106
+ display: none !important;
107
+ width: 0;
108
+ height: 0;
109
+ }
110
+ .mobile-gateway-panel .mgw-body { display: grid; grid-template-columns: 180px minmax(0, 1fr); gap: 28px; flex: 1; min-height: 0; }
111
+ .mobile-gateway-panel .mgw-nav { display: flex; flex-direction: column; gap: 4px; }
112
+ .mobile-gateway-panel .mgw-nav button { text-align: left; border: 0; padding: 12px 14px; border-radius: 10px; background: transparent; color: inherit; font: inherit; cursor: pointer; }
113
+ .mobile-gateway-panel .mgw-nav button[aria-current="page"] { background: var(--dsw-alias-bg-overlay, rgba(127,127,127,.12)); font-weight: 500; }
114
+ .mobile-gateway-panel .mgw-nav button:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.08)); }
115
+ .mobile-gateway-panel .mgw-content { min-width: 0; overflow-y: auto; scrollbar-width: none; padding-right: 4px; }
116
+ .mobile-gateway-panel .mgw-content::-webkit-scrollbar { display: none; }
117
+ .mobile-gateway-panel .mgw-content p { margin: 6px 0 10px; }
118
+ .mobile-gateway-panel strong { font-weight: 550; }
119
+ .mobile-gateway-panel .mgw-section-title { margin: 0 0 10px; font-size: 17px; line-height: 1.4; font-weight: 650; color: inherit; }
120
+ .mobile-gateway-panel .mgw-setting-row { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; margin-bottom: 12px; }
121
+ .mobile-gateway-panel .mgw-setting-row select { width: auto !important; max-width: 100%; min-width: 150px; margin-bottom: 0 !important; flex-shrink: 1; }
122
+ .mobile-gateway-panel .mgw-cloudflare-block input, .mobile-gateway-panel .mgw-cloudflare-block .mgw-select-trigger { background: var(--dsw-alias-bg-layer-2, Canvas) !important; }
123
+ .mobile-gateway-panel .mgw-setting-label { font-size: 14px; font-weight: 550; color: inherit; }
124
+
125
+ .mobile-gateway-panel input:focus-visible, .mobile-gateway-panel select:focus-visible, .mobile-gateway-panel button:focus-visible { outline: 2px solid var(--dsw-alias-label-primary, currentColor); outline-offset: 2px; }
126
+ @media (max-width: 640px) {
127
+ .mobile-gateway-panel .mgw-body { grid-template-columns: minmax(0, 1fr); gap: 8px; }
128
+ .mobile-gateway-panel .mgw-nav { flex-direction: row; }
129
+ .mobile-gateway-panel .mgw-nav button { flex: 1; padding: 10px 6px; text-align: center; }
130
+ }
131
+ `
132
+
133
+ function GatewaySelect({ style, children, value, onChange, disabled, ...props }) {
134
+ const options = React.Children.toArray(children).filter((child) => React.isValidElement(child))
135
+ const [menu, setMenu] = React.useState(null)
136
+ const [active, setActive] = React.useState(0)
137
+ const buttonRef = React.useRef(null)
138
+ const menuRef = React.useRef(null)
139
+ const menuId = React.useId()
140
+ const selected = options.find((option) => option.props.value === value)
141
+ const enabled = options.map((option, index) => option.props.disabled ? -1 : index).filter((index) => index >= 0)
142
+ const close = (restoreFocus = false) => {
143
+ setMenu(null)
144
+ if (restoreFocus) buttonRef.current?.focus()
145
+ }
146
+ const openMenu = () => {
147
+ if (disabled) return
148
+ const rect = buttonRef.current.getBoundingClientRect()
149
+ const computed = window.getComputedStyle(buttonRef.current)
150
+ const fontSize = parseFloat(computed.fontSize) || 14
151
+ const lineHeight = parseFloat(computed.lineHeight) || fontSize * 1.5
152
+ const measure = document.createElement('canvas').getContext('2d')
153
+ if (measure) measure.font = computed.font || `${fontSize}px sans-serif`
154
+ const longest = Math.max(...options.map((option) => measure
155
+ ? measure.measureText(String(option.props.children)).width : String(option.props.children).length * fontSize))
156
+ const height = Math.min(options.length * Math.max(40, lineHeight + 16) + 14, window.innerHeight - 24)
157
+ const width = Math.min(Math.max(rect.width, longest + 72, 200), window.innerWidth - 24)
158
+ setActive(Math.max(0, options.findIndex((option) => option.props.value === value && !option.props.disabled)))
159
+ setMenu({ left: Math.max(12, Math.min(rect.left, window.innerWidth - width - 12)),
160
+ top: Math.max(12, rect.bottom + height + 6 <= window.innerHeight - 12 ? rect.bottom + 6 : rect.top - height - 6), width, maxHeight: window.innerHeight - 24 })
161
+ }
162
+ const choose = (index) => {
163
+ const option = options[index]
164
+ if (!option || option.props.disabled) return
165
+ onChange?.({ target: { value: option.props.value } })
166
+ close(true)
167
+ }
168
+ React.useEffect(() => {
169
+ if (!menu) return
170
+ menuRef.current?.focus()
171
+ const outside = (event) => {
172
+ if (!buttonRef.current?.contains(event.target) && !menuRef.current?.contains(event.target)) close()
173
+ }
174
+ const scrolled = (event) => { if (!menuRef.current?.contains(event.target)) close() }
175
+ const resized = () => close()
176
+ document.addEventListener('pointerdown', outside)
177
+ document.addEventListener('scroll', scrolled, true)
178
+ window.addEventListener('resize', resized)
179
+ return () => {
180
+ document.removeEventListener('pointerdown', outside)
181
+ document.removeEventListener('scroll', scrolled, true)
182
+ window.removeEventListener('resize', resized)
183
+ }
184
+ }, [menu])
185
+ React.useEffect(() => {
186
+ if (menu) document.getElementById(`${menuId}-${active}`)?.scrollIntoView({ block: 'nearest' })
187
+ }, [active, menu])
188
+ return React.createElement('span', { style: { display: 'inline-flex', maxWidth: '100%', minWidth: 0 } },
189
+ React.createElement('button', { ...props, className: 'mgw-select-trigger', ref: buttonRef, type: 'button', disabled,
190
+ 'aria-haspopup': 'listbox', 'aria-expanded': !!menu, 'aria-controls': menu ? menuId : undefined,
191
+ onClick: () => menu ? close() : openMenu(),
192
+ onKeyDown: (event) => {
193
+ if (['ArrowDown', 'ArrowUp'].includes(event.key)) { event.preventDefault(); openMenu() }
194
+ },
195
+ style: { ...style, width: 'auto', maxWidth: '100%', marginBottom: 0, border: 0, borderRadius: 12,
196
+ padding: '10px 16px', background: colors.card, color: colors.text, font: 'inherit', cursor: disabled ? 'default' : 'pointer',
197
+ display: 'inline-flex', alignItems: 'center', gap: 18, opacity: disabled ? .5 : 1 } },
198
+ React.createElement('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, selected?.props.children || value),
199
+ React.createElement('svg', { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor',
200
+ strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, style: { flexShrink: 0 } },
201
+ React.createElement('path', { d: 'M6 9l6 6 6-6' }))),
202
+ menu ? React.createElement('div', { className: 'mgw-select-menu', ref: menuRef, id: menuId, role: 'listbox', tabIndex: -1,
203
+ 'aria-label': props['aria-label'], 'aria-activedescendant': `${menuId}-${active}`,
204
+ style: { ...menu, position: 'fixed', zIndex: 1250, boxSizing: 'border-box', overflowY: 'auto', padding: 6,
205
+ background: colors.panel, color: colors.text, border: `1px solid ${colors.border}`, borderRadius: 14,
206
+ boxShadow: 'var(--dsw-elevation-prominent, 0 8px 28px rgba(0,0,0,.12))', outline: 'none' },
207
+ onKeyDown: (event) => {
208
+ if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true) }
209
+ else if (event.key === 'Tab') close()
210
+ else if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(active) }
211
+ else if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
212
+ event.preventDefault()
213
+ const index = enabled.indexOf(active)
214
+ setActive(event.key === 'Home' ? enabled[0] : event.key === 'End' ? enabled[enabled.length - 1]
215
+ : enabled[(index + (event.key === 'ArrowDown' ? 1 : -1) + enabled.length) % enabled.length])
216
+ }
217
+ } }, options.map((option, index) => React.createElement('div', { key: option.props.value,
218
+ id: `${menuId}-${index}`, role: 'option', 'aria-selected': option.props.value === value, 'aria-disabled': !!option.props.disabled,
219
+ onPointerMove: () => { if (!option.props.disabled) setActive(index) }, onClick: () => choose(index),
220
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, minHeight: 40, whiteSpace: 'nowrap',
221
+ boxSizing: 'border-box', padding: '8px 12px', borderRadius: 8, cursor: option.props.disabled ? 'default' : 'pointer',
222
+ opacity: option.props.disabled ? .4 : 1, background: active === index ? 'var(--dsw-alias-interactive-bg-hover, rgba(127,127,127,.08))' : 'transparent' } },
223
+ React.createElement('span', { style: { minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis' }, title: String(option.props.children) }, option.props.children), option.props.value === value ? React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 24 24',
224
+ fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true, style: { flexShrink: 0 } },
225
+ React.createElement('path', { d: 'M5 12l4 4L19 6' })) : null))) : null)
226
+ }
227
+
71
228
  async function request(path, options) {
72
229
  const response = await fetch(path, { credentials: 'same-origin', ...options })
73
230
  const body = await response.json().catch(() => ({}))
@@ -99,10 +256,10 @@ window.__ModuleLoader__.load({
99
256
  const system = status.platform === 'darwin' ? 'macOS 电脑' : status.platform === 'win32' ? 'Windows 电脑' : status.platform === 'linux' ? 'Linux 服务器' : '当前设备'
100
257
  const lanReady = !!lanPairingUrl(status)
101
258
  const tunnel = status.cloudflare
102
- const tunnelReady = !!(tunnel?.enabled && tunnel.state === 'online' && tunnel.publicUrl)
259
+ const tunnelReady = !!(tunnel?.enabled && tunnel.publicReady === true && tunnel.publicUrl)
103
260
  const serverReady = status.platform === 'linux' && !!(
104
261
  (publicSetup?.configured && publicSetup.publicUrl) || /^wss:\/\//.test(status.publicUrl || ''))
105
- const detected = `当前检测:${system};局域网入口${lanReady ? '已就绪' : '未就绪'};${serverReady ? '公网服务器入口已配置' : tunnelReady ? `${tunnel.mode === 'quick' ? 'Quick' : '命名'} Tunnel 已连接` : tunnel?.enabled ? 'Cloudflare Tunnel 连接中' : '外网入口未连接'}。`
262
+ const detected = `当前检测:${system};局域网入口${lanReady ? '已就绪' : '未就绪'};${serverReady ? '公网服务器入口已配置' : tunnelReady ? `${tunnel.mode === 'quick' ? 'Quick' : '命名'} Tunnel 已连接` : tunnel?.enabled ? (tunnel.state === 'online' ? '隧道已连接,公网入口未验证或检测失败' : 'Cloudflare Tunnel 连接中') : '外网入口未连接'}。`
106
263
  const local = lanReady
107
264
  ? '手机与 DSH 处于可互访的同一局域网时,建议选“局域网直连”。'
108
265
  : '如果手机和 DSH 在同一局域网,先确认局域网入口已开启并监听成功。'
@@ -125,10 +282,10 @@ window.__ModuleLoader__.load({
125
282
  return React.createElement('div', { style: styles.row },
126
283
  React.createElement('div', { style: { minWidth: 0, overflow: 'hidden' } },
127
284
  React.createElement('div', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
128
- device.name,
285
+ device.displayName || device.name,
129
286
  React.createElement('span', { style: styles.badge },
130
287
  React.createElement('span', { style: { ...styles.dot, background: device.online ? colors.success : colors.muted } }),
131
- device.online ? `在线${device.connections > 1 ? ` · ${device.connections} 个连接` : ''}` : '离线')),
288
+ device.online ? '在线' : '离线')),
132
289
  React.createElement('div', { style: { ...styles.muted, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', marginTop: 3 } },
133
290
  device.lastSeenAt ? `最近连接:${new Date(device.lastSeenAt).toLocaleString()}` : '尚未连接')),
134
291
  React.createElement('button', {
@@ -143,7 +300,7 @@ window.__ModuleLoader__.load({
143
300
  minWidth: 54,
144
301
  opacity: revoking ? .6 : 1,
145
302
  },
146
- title: `吊销 ${device.name}`,
303
+ title: `吊销 ${device.displayName || device.name}`,
147
304
  // Pointer-up is more reliable than click inside the shell overlay on
148
305
  // touch devices. The click handler remains for keyboard activation.
149
306
  onPointerUp: triggerRevoke,
@@ -154,8 +311,11 @@ window.__ModuleLoader__.load({
154
311
  }
155
312
 
156
313
  function DevicePanel() {
314
+ const [activeSection, setActiveSection] = React.useState('general')
157
315
  const [devices, setDevices] = React.useState(null)
316
+ const [showAllDevices, setShowAllDevices] = React.useState(false)
158
317
  const [status, setStatus] = React.useState(null)
318
+ const [dismissedNotice, setDismissedNotice] = React.useState(() => dismissedRestartInstance)
159
319
  const [publicUrl, setPublicUrl] = React.useState('')
160
320
  const [pairingRoute, setPairingRoute] = React.useState('auto')
161
321
  const [connectionHelpOpen, setConnectionHelpOpen] = React.useState(false)
@@ -170,8 +330,32 @@ window.__ModuleLoader__.load({
170
330
  const [cloudflareToken, setCloudflareToken] = React.useState('')
171
331
  const [cloudflareMode, setCloudflareMode] = React.useState('quick')
172
332
  const [cloudflareBusy, setCloudflareBusy] = React.useState(false)
173
- const [deviceName, setDeviceName] = React.useState('iPhone')
174
333
  const [qr, setQr] = React.useState(null)
334
+ const pairingDialogRef = React.useRef(null)
335
+ const [pairingClosing, setPairingClosing] = React.useState(false)
336
+ const pairingCloseTimer = React.useRef(null)
337
+ const closePairingDialog = () => {
338
+ if (pairingCloseTimer.current !== null) return
339
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
340
+ setQr(null)
341
+ return
342
+ }
343
+ setPairingClosing(true)
344
+ pairingCloseTimer.current = window.setTimeout(() => {
345
+ pairingCloseTimer.current = null
346
+ setQr(null)
347
+ setPairingClosing(false)
348
+ }, 180)
349
+ }
350
+ React.useEffect(() => {
351
+ setPairingClosing(false)
352
+ return () => {
353
+ if (pairingCloseTimer.current !== null) {
354
+ window.clearTimeout(pairingCloseTimer.current)
355
+ pairingCloseTimer.current = null
356
+ }
357
+ }
358
+ }, [qr])
175
359
  const [error, setError] = React.useState(null)
176
360
  const [busy, setBusy] = React.useState(false)
177
361
  const [refreshing, setRefreshing] = React.useState(false)
@@ -288,6 +472,13 @@ window.__ModuleLoader__.load({
288
472
  return () => { active = false; window.clearInterval(timer) }
289
473
  }, [refresh])
290
474
 
475
+ React.useEffect(() => {
476
+ if (!qr) return
477
+ const previousFocus = document.activeElement
478
+ pairingDialogRef.current?.querySelector('button')?.focus()
479
+ return () => previousFocus?.focus()
480
+ }, [qr])
481
+
291
482
  const choosePairingRoute = (route) => {
292
483
  pairingRouteRef.current = route
293
484
  setPairingRoute(route)
@@ -325,7 +516,7 @@ window.__ModuleLoader__.load({
325
516
  }
326
517
  }
327
518
 
328
- const configureCloudflare = async (enabled) => {
519
+ const configureCloudflare = async (enabled, mode = cloudflareMode) => {
329
520
  if (cloudflareBusy) return
330
521
  setCloudflareBusy(true)
331
522
  setError(null)
@@ -334,8 +525,8 @@ window.__ModuleLoader__.load({
334
525
  method: 'POST',
335
526
  headers: { 'Content-Type': 'application/json' },
336
527
  body: JSON.stringify({ enabled, ...(enabled ? {
337
- mode: cloudflareMode,
338
- ...(cloudflareMode === 'named' ? { hostname: cloudflareHost, token: cloudflareToken } : {}),
528
+ mode,
529
+ ...(mode === 'named' ? { hostname: cloudflareHost, token: cloudflareToken } : {}),
339
530
  } : {}) }),
340
531
  })
341
532
  setCloudflareToken('')
@@ -352,10 +543,37 @@ window.__ModuleLoader__.load({
352
543
  }
353
544
  }
354
545
 
546
+ const checkCloudflare = async () => {
547
+ if (cloudflareBusy) return
548
+ setCloudflareBusy(true)
549
+ setError(null)
550
+ try {
551
+ const data = await request('/mgw/cloudflare/check', { method: 'POST' })
552
+ setStatus((current) => ({ ...(current || {}), cloudflare: data }))
553
+ } catch (cause) { setError(cause.message) }
554
+ finally { setCloudflareBusy(false) }
555
+ }
556
+
557
+ const restartCloudflare = async () => {
558
+ if (cloudflareBusy) return
559
+ setCloudflareBusy(true)
560
+ setError(null)
561
+ try {
562
+ const data = await request('/mgw/cloudflare/restart', { method: 'POST' })
563
+ if (data.mode === 'quick') setQr(null)
564
+ setStatus((current) => ({ ...(current || {}), cloudflare: data }))
565
+ await refresh()
566
+ } catch (cause) {
567
+ setError(cause.message)
568
+ } finally {
569
+ setCloudflareBusy(false)
570
+ }
571
+ }
572
+
355
573
  const createPairing = async (endpoint) => request('/mgw/pair', {
356
574
  method: 'POST',
357
575
  headers: { 'Content-Type': 'application/json' },
358
- body: JSON.stringify({ name: deviceName, publicUrl: endpoint }),
576
+ body: JSON.stringify({ publicUrl: endpoint }),
359
577
  })
360
578
 
361
579
  const pair = async () => {
@@ -435,7 +653,7 @@ window.__ModuleLoader__.load({
435
653
  try {
436
654
  await request(`/mgw/devices/${encodeURIComponent(device.id)}/revoke`, { method: 'POST' })
437
655
  setDevices((current) => Array.isArray(current) ? current.filter((item) => item.id !== device.id) : current)
438
- setDeviceNotice(`已吊销 ${device.name}`)
656
+ setDeviceNotice(`已吊销 ${device.displayName || device.name}`)
439
657
  window.setTimeout(() => setDeviceNotice(null), 2200)
440
658
  } catch (cause) {
441
659
  setDeviceNoticeError(true)
@@ -452,23 +670,59 @@ window.__ModuleLoader__.load({
452
670
  }
453
671
  }
454
672
 
673
+ const restartNoticeInstance = status?.gatewayInstanceId || (status?.webPid ? String(status.webPid) : null)
674
+ const dismissRestartNotice = () => {
675
+ dismissedRestartInstance = restartNoticeInstance
676
+ setDismissedNotice(restartNoticeInstance)
677
+ try { window.localStorage.setItem(restartNoticeStorageKey, restartNoticeInstance) } catch { /* keep in-memory dismissal */ }
678
+ }
679
+
680
+ const onlineDevices = (devices || []).filter((device) => device.online)
681
+ const offlineDevices = (devices || []).filter((device) => !device.online)
682
+ const visibleDevices = showAllDevices ? [...onlineDevices, ...offlineDevices] : onlineDevices
683
+
455
684
  const advice = connectionAdvice(status, publicSetup)
456
685
  const helpLine = (name, description) => React.createElement('div', { style: { marginTop: 7 } },
457
686
  React.createElement('strong', null, name), ':', description)
458
687
 
459
688
  return React.createElement('div', { style: styles.backdrop, onMouseDown: () => setOpen(false) },
460
- React.createElement('aside', { style: styles.panel, onMouseDown: (event) => { event.stopPropagation(); setToolsOpen(false) }, role: 'dialog', 'aria-modal': true, 'aria-label': '移动设备管理' },
689
+ React.createElement('style', null, panelCss),
690
+ qr ? React.createElement('div', { style: { ...styles.backdrop, zIndex: 1201 },
691
+ onMouseDown: (event) => { event.stopPropagation(); closePairingDialog() } },
692
+ React.createElement('div', { className: 'mgw-pairing-dialog', 'data-closing': pairingClosing, ref: pairingDialogRef, role: 'dialog', 'aria-modal': true, 'aria-label': '设备配对二维码', tabIndex: -1,
693
+ style: { ...styles.panel, width: 'min(520px, 100%)', height: 'auto', overflowY: 'auto', scrollbarWidth: 'none' },
694
+ onMouseDown: (event) => event.stopPropagation(),
695
+ onKeyDown: (event) => {
696
+ if (event.key === 'Escape') { event.stopPropagation(); closePairingDialog() }
697
+ if (event.key === 'Tab') {
698
+ const items = [...event.currentTarget.querySelectorAll('button, textarea')]
699
+ const first = items[0], last = items[items.length - 1]
700
+ if (event.shiftKey && (document.activeElement === first || document.activeElement === event.currentTarget)) { event.preventDefault(); last?.focus() }
701
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus() }
702
+ }
703
+ } },
704
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end' } },
705
+ React.createElement('button', { type: 'button', style: { ...styles.button, ...styles.headerButton }, 'aria-label': '关闭配对窗口', onClick: closePairingDialog }, '✕')),
706
+ React.createElement('section', { style: { textAlign: 'center' } },
707
+ React.createElement('strong', null, '使用移动客户端扫码'),
708
+ React.createElement('img', { style: styles.qr, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.svg)}`, alt: '一次性设备配对二维码' }),
709
+ React.createElement('label', { style: { ...styles.label, textAlign: 'left' } }, 'Base64URL 配对字符串'),
710
+ React.createElement('textarea', { style: styles.pairingText, value: qr.qrPayload, readOnly: true, spellCheck: false, onFocus: (event) => event.target.select() }),
711
+ React.createElement('button', { style: { ...styles.button, width: '100%', marginBottom: 8, borderColor: pairingTokenCopied ? colors.success : colors.border, color: pairingTokenCopied ? colors.success : colors.text }, onClick: copyPairingText }, pairingTokenCopied ? '✓ 已复制' : '复制配对 Token'),
712
+ pairingTokenCopied ? React.createElement('div', { style: { ...styles.muted, color: colors.success, marginBottom: 8 } }, '已复制到剪贴板') : null,
713
+ React.createElement('div', { style: styles.muted }, `有效期至 ${new Date(qr.pairing.expiresAt).toLocaleTimeString()};扫码成功后此二维码立即失效。`)))) : null,
714
+ React.createElement('aside', { className: 'mobile-gateway-panel', inert: qr ? true : undefined, 'aria-hidden': qr ? true : undefined, style: styles.panel, onMouseDown: (event) => { event.stopPropagation(); setToolsOpen(false) }, role: 'dialog', 'aria-modal': true, 'aria-label': '移动设备管理' },
461
715
  React.createElement('div', { style: styles.header },
462
716
  React.createElement('div', null,
463
717
  React.createElement('h2', { style: styles.title }, '移动设备'),
464
- React.createElement('div', { style: styles.muted }, status && status.gatewayEnabled ? '移动网关已开启 · 设备鉴权生效中' : '移动网关已关闭 · 普通 WebUI 模式')),
718
+ React.createElement('div', { style: styles.muted }, !status ? '正在加载网关状态…' : status.gatewayEnabled ? `移动网关已开启 · ${status.requireAuth ? '设备鉴权已开启' : '本机设备鉴权已关闭'}` : '移动网关已关闭 · 普通 WebUI 模式')),
465
719
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
466
720
  React.createElement('div', { style: { position: 'relative' }, onMouseDown: (event) => event.stopPropagation() },
467
721
  React.createElement('button', {
468
722
  type: 'button', style: { ...styles.button, ...styles.headerButton, borderRadius: '50%' }, title: '工具', 'aria-label': '工具',
469
723
  'aria-haspopup': 'menu', 'aria-expanded': toolsOpen,
470
724
  onClick: () => setToolsOpen((value) => !value),
471
- }, React.createElement('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
725
+ }, React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true },
472
726
  React.createElement('path', { d: 'M10.3 2.8h3.4l.5 2.2a7.6 7.6 0 0 1 1.7.7l1.9-1.2 2.4 2.4L19 8.8c.3.5.5 1.1.7 1.7l2.2.5v3.4l-2.2.5a7.6 7.6 0 0 1-.7 1.7l1.2 1.9-2.4 2.4-1.9-1.2a7.6 7.6 0 0 1-1.7.7l-.5 2.2h-3.4l-.5-2.2a7.6 7.6 0 0 1-1.7-.7l-1.9 1.2-2.4-2.4L5 16.6a7.6 7.6 0 0 1-.7-1.7l-2.2-.5V11l2.2-.5A7.6 7.6 0 0 1 5 8.8L3.8 6.9l2.4-2.4 1.9 1.2a7.6 7.6 0 0 1 1.7-.7l.5-2.2Z' }),
473
727
  React.createElement('circle', { cx: 12, cy: 12.7, r: 3.1 }))),
474
728
  toolsOpen ? React.createElement('div', { style: styles.toolsMenu, role: 'menu', 'aria-label': '网关工具' },
@@ -485,22 +739,40 @@ window.__ModuleLoader__.load({
485
739
  }, '停止 DSH Web',
486
740
  React.createElement('span', { style: { ...styles.muted, display: 'block', marginTop: 2 } }, '结束后台进程,所有连接将断开')))
487
741
  : null),
488
- React.createElement('button', { style: { ...styles.button, ...styles.headerButton }, onClick: () => setOpen(false), 'aria-label': '关闭' }, '✕'))),
742
+ React.createElement('button', { style: { ...styles.button, ...styles.headerButton }, onClick: () => setOpen(false), 'aria-label': '关闭' },
743
+ React.createElement('svg', { width: 20, height: 20, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2.2, strokeLinecap: 'round', 'aria-hidden': true },
744
+ React.createElement('path', { d: 'M6 6l12 12M18 6L6 18' }))))),
745
+ React.createElement('div', { className: 'mgw-body' },
746
+ React.createElement('nav', { className: 'mgw-nav', 'aria-label': '移动网关设置分组' },
747
+ [['general', '通用设置'], ['pairing', '连接与配对'], ['devices', '可信设备']].map(([id, label]) =>
748
+ React.createElement('button', { key: id, type: 'button', 'aria-current': activeSection === id ? 'page' : undefined,
749
+ onClick: () => { setActiveSection(id); setConnectionHelpOpen(false); setToolsOpen(false) } }, label))),
750
+ React.createElement('div', { className: 'mgw-content', key: activeSection },
751
+ restartNoticeInstance && dismissedNotice !== restartNoticeInstance ? React.createElement('section', { style: { ...styles.card, position: 'relative', padding: '10px 40px 10px 12px', border: '1px solid', borderRadius: 10, marginBottom: 8, background: 'var(--dsw-alias-state-warn-tertiary, #fff7df)', borderColor: 'color-mix(in srgb, #d99a16 35%, transparent)', fontSize: 12, lineHeight: 1.6 }, role: 'note' },
752
+ React.createElement('button', { type: 'button', 'aria-label': '关闭重启提醒', title: '关闭提醒',
753
+ style: { ...styles.button, position: 'absolute', right: 8, top: 8, border: 0, width: 24, height: 24, padding: 0, color: colors.muted },
754
+ onClick: dismissRestartNotice }, '✕'),
755
+ React.createElement('strong', null, '若安装或更新后连接异常,请重启应用'),
756
+ React.createElement('div', { style: { color: colors.muted, marginTop: 4 } },
757
+ '完全退出并重启 APP/Desktop;Web UI 请重启 DSH Web 服务。'),
758
+ React.createElement('div', { style: { color: colors.muted, marginTop: 4 } },
759
+ '端口仍被占用时,请关闭其他 Harness 实例。')) : null,
489
760
  toolNotice ? React.createElement('div', { style: { ...styles.muted, marginBottom: 10 } }, toolNotice) : null,
490
761
  error ? React.createElement('div', { style: styles.error }, error) : null,
762
+ React.createElement('div', { hidden: activeSection !== 'general' },
491
763
  React.createElement('section', { style: styles.card },
492
764
  React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
493
765
  React.createElement('div', null,
494
766
  React.createElement('strong', null, '网关运行模式'),
495
767
  React.createElement('div', { style: { ...styles.muted, marginTop: 4 } }, status && status.gatewayEnabled
496
768
  ? status.gatewayMode === 'persistent'
497
- ? '常驻开启,重启后保持;无需设备在线'
769
+ ? '重启后保持开启'
498
770
  : status.waitExpiresAt
499
771
  ? `等待可信设备连接,约 ${Math.max(1, Math.ceil((status.waitExpiresAt - Date.now()) / 60000))} 分钟后自动关闭`
500
772
  : '设备已连接过,本次运行保持开启;重启后重新计时'
501
- : '关闭时不会接受移动设备连接,重启后保持关闭')),
773
+ : '不接受移动设备连接')),
502
774
  React.createElement('span', { style: { position: 'relative', display: 'inline-flex', flexShrink: 0 } },
503
- React.createElement('select', {
775
+ React.createElement(GatewaySelect, {
504
776
  style: { ...styles.input, width: 'auto', marginBottom: 0, padding: '8px 40px 8px 12px', appearance: 'none', WebkitAppearance: 'none' },
505
777
  'aria-label': '网关运行模式',
506
778
  value: status ? status.gatewayMode || (status.gatewayEnabled ? 'temporary' : 'disabled') : 'disabled',
@@ -510,16 +782,9 @@ window.__ModuleLoader__.load({
510
782
  React.createElement('option', { value: 'disabled' }, '关闭'),
511
783
  React.createElement('option', { value: 'temporary' }, '临时开启'),
512
784
  React.createElement('option', { value: 'persistent' }, '常驻开启')),
513
- React.createElement('span', {
514
- 'aria-hidden': true,
515
- style: {
516
- position: 'absolute', right: 15, top: '50%', width: 7, height: 7,
517
- borderRight: `1.5px solid ${colors.text}`, borderBottom: `1.5px solid ${colors.text}`,
518
- transform: 'translateY(-65%) rotate(45deg)', pointerEvents: 'none',
519
- },
520
- }))),
785
+ )),
521
786
  status && status.gatewayId ? React.createElement('div', { style: { ...styles.muted, marginTop: 12, overflowWrap: 'anywhere' } },
522
- `${status.gatewayName} · ${status.gatewayId}`) : null),
787
+ status.gatewayName) : null),
523
788
  React.createElement('section', { style: styles.card },
524
789
  React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center' } },
525
790
  React.createElement('div', null,
@@ -541,68 +806,12 @@ window.__ModuleLoader__.load({
541
806
  status && !status.requireAuth
542
807
  ? React.createElement('p', { style: { ...styles.error, margin: '12px 0 0' } }, 'Debug 模式:本机 DSH 入口将跳过设备凭证校验;独立局域网入口仍强制鉴权。')
543
808
  : null),
544
- status && status.cloudflare && status.cloudflare.supported
545
- ? React.createElement('section', { style: styles.card },
546
- React.createElement('strong', null, 'Cloudflare Tunnel · PC 外网接入'),
547
- React.createElement('p', { style: styles.muted }, '首次开启时自动下载并校验 cloudflared;公网入口只接受已鉴权的移动 WebSocket。'),
548
- React.createElement('label', { style: styles.label }, '接入方式'),
549
- React.createElement('select', { style: styles.input, value: cloudflareMode, onChange: (event) => setCloudflareMode(event.target.value), disabled: cloudflareBusy },
550
- React.createElement('option', { value: 'quick' }, 'Quick Tunnel · 无需域名'),
551
- React.createElement('option', { value: 'named' }, '命名 Tunnel · 固定域名')),
552
- cloudflareMode === 'quick'
553
- ? React.createElement('p', { style: styles.muted }, 'Cloudflare 会分配随机地址;DSH 重启或隧道重连后地址可能改变,手机需重新扫码确认新地址。适合临时使用。')
554
- : React.createElement(React.Fragment, null,
555
- React.createElement('p', { style: styles.muted }, '先在 Cloudflare 创建命名 Tunnel,把公开域名的 Service URL 设为 ', React.createElement('code', null, `http://127.0.0.1:${status.cloudflare.port}`), '。'),
556
- React.createElement('label', { style: styles.label }, '公开域名'),
557
- React.createElement('input', { style: styles.input, value: cloudflareHost, onChange: (event) => setCloudflareHost(event.target.value.trim()), placeholder: 'gateway.example.com', spellCheck: false }),
558
- React.createElement('label', { style: styles.label }, 'Tunnel Token'),
559
- React.createElement('input', { style: styles.input, type: 'password', value: cloudflareToken, onChange: (event) => setCloudflareToken(event.target.value.trim()), placeholder: status.cloudflare.configured ? '已保存;留空则保持原 Token' : '粘贴 eyJ… Token', autoComplete: 'off', spellCheck: false })),
560
- React.createElement('button', {
561
- style: { ...styles.button, ...styles.primary, opacity: cloudflareBusy || (cloudflareMode === 'named' && (!cloudflareHost || (!cloudflareToken && !status.cloudflare.configured))) ? .65 : 1 },
562
- disabled: cloudflareBusy || (cloudflareMode === 'named' && (!cloudflareHost || (!cloudflareToken && !status.cloudflare.configured))),
563
- onClick: () => configureCloudflare(true),
564
- }, cloudflareBusy ? '正在处理…' : status.cloudflare.enabled && cloudflareMode === status.cloudflare.mode ? '更新 Tunnel 配置' : cloudflareMode === 'quick' ? '一键开启 Quick Tunnel' : '开启命名 Tunnel'),
565
- status.cloudflare.enabled
566
- ? React.createElement('button', { style: { ...styles.button, width: '100%', marginTop: 8 }, disabled: cloudflareBusy, onClick: () => configureCloudflare(false) }, '关闭 Cloudflare Tunnel')
567
- : null,
568
- React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere', color: status.cloudflare.error ? colors.danger : colors.muted } },
569
- status.cloudflare.error || ({ disabled: '未开启', starting: '正在启动本机入口…', preparing: '正在准备 cloudflared(首次需下载)…', connecting: '正在连接 Cloudflare…', online: '已连接 Cloudflare', 'waiting-for-gateway': '等待移动网关开启' }[status.cloudflare.state] || status.cloudflare.state),
570
- status.cloudflare.publicUrl ? ` · ${status.cloudflare.publicUrl}` : ''))
571
- : null,
572
- status && status.platform === 'linux' ? React.createElement('section', { style: styles.card },
573
- React.createElement('strong', null, '公网接入'),
574
- publicSetup === null
575
- ? React.createElement('p', { style: styles.muted }, '正在检查系统组件…')
576
- : publicSetup.installed
577
- ? React.createElement(React.Fragment, null,
578
- React.createElement('div', { style: { ...styles.muted, margin: '5px 0 12px' } }, publicSetup.configured
579
- ? publicSetup.backendPort && status && status.webPort && publicSetup.backendPort !== status.webPort
580
- ? `端口需要更新:Nginx 当前为 ${publicSetup.backendPort},DSH 当前为 ${status.webPort}`
581
- : `已配置${publicSetup.backendPort ? `,Nginx 转发至 DSH 端口 ${publicSetup.backendPort}` : ''}`
582
- : `Helper 已就绪,将自动使用当前 DSH 端口${status && status.webPort ? ` ${status.webPort}` : ''}`),
583
- React.createElement('label', { style: styles.label }, '服务器公网 IPv4'),
584
- React.createElement('input', {
585
- style: styles.input,
586
- value: publicIp,
587
- onChange: (event) => setPublicIp(event.target.value.trim()),
588
- placeholder: '203.0.113.10',
589
- inputMode: 'decimal',
590
- spellCheck: false,
591
- }),
592
- React.createElement('button', {
593
- style: { ...styles.button, ...styles.primary, opacity: publicSetupBusy || !publicIp ? .65 : 1 },
594
- disabled: publicSetupBusy || !publicIp,
595
- onClick: configurePublicAccess,
596
- }, publicSetupBusy ? '正在配置 Nginx 与证书…' : publicSetup.configured ? '更新公网配置' : '配置公网接入'),
597
- publicSetup.publicUrl
598
- ? React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere' } }, publicSetup.publicUrl)
599
- : null)
600
- : React.createElement(React.Fragment, null,
601
- React.createElement('p', { style: { ...styles.muted, marginBottom: 8 } }, '尚未安装系统 Helper。请在服务器执行一次初始化:'),
602
- React.createElement('code', { style: { display: 'block', fontSize: 10, lineHeight: 1.5, overflowWrap: 'anywhere', userSelect: 'all' } }, 'npx --yes dsh-plugin-mobile-gateway@latest init'))) : null,
809
+ ),
810
+ React.createElement('div', { hidden: activeSection !== 'pairing' },
603
811
  React.createElement('section', { style: styles.card },
812
+ React.createElement('div', { className: 'mgw-setting-row' },
604
813
  React.createElement('div', { style: { position: 'relative', display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }, onMouseLeave: () => setConnectionHelpOpen(false) },
605
- React.createElement('span', { style: { ...styles.label, marginBottom: 0 } }, '配对连接方式'),
814
+ React.createElement('h3', { className: 'mgw-section-title', style: { marginBottom: 0 } }, '配对连接方式'),
606
815
  React.createElement('button', {
607
816
  type: 'button',
608
817
  style: styles.helpButton,
@@ -621,51 +830,126 @@ window.__ModuleLoader__.load({
621
830
  helpLine('Cloudflare Channel(命名 Tunnel)', 'PC 主动连接 Cloudflare,外网用固定域名的 wss://;需要 Cloudflare 账户和域名。'),
622
831
  helpLine('Quick Channel(Quick Tunnel)', '无需账户或域名,Cloudflare 分配临时 wss:// 地址;隧道重建后地址可能变化。'),
623
832
  helpLine('公网服务器连接', 'DSH 运行在有固定公网 IPv4 的 Linux 服务器上,配置公网入口后用 wss:// 公网 IP 连接。'),
624
- React.createElement('div', { style: { borderTop: '1px solid rgba(255,255,255,.18)', marginTop: 10, paddingTop: 8 } },
833
+ React.createElement('div', { style: { borderTop: `1px solid ${colors.border}`, marginTop: 10, paddingTop: 8 } },
625
834
  React.createElement('strong', null, '结合当前环境'),
626
- React.createElement('div', { style: { color: '#c6cbd4', marginTop: 4 } }, advice.detected),
835
+ React.createElement('div', { style: { color: colors.muted, marginTop: 4 } }, advice.detected),
627
836
  React.createElement('div', { style: { marginTop: 6 } }, advice.local),
628
837
  React.createElement('div', { style: { marginTop: 6 } }, advice.remote),
629
- React.createElement('div', { style: { color: '#aeb6c3', marginTop: 6 } }, '面板无法判断手机当前网络;请按手机所在位置选择,提示不会替你修改选择。')))
838
+ React.createElement('div', { style: { color: colors.muted, marginTop: 6 } }, '面板无法判断手机当前网络;请按手机所在位置选择,提示不会替你修改选择。')))
630
839
  : null),
631
- React.createElement('select', { style: styles.input, value: pairingRoute, onChange: (event) => choosePairingRoute(event.target.value), disabled: !status, 'aria-label': '配对连接方式' },
840
+ React.createElement('div', { style: { display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px 14px', maxWidth: '100%' } },
841
+ React.createElement(GatewaySelect, { style: styles.input, value: pairingRoute, onChange: (event) => choosePairingRoute(event.target.value), disabled: !status, 'aria-label': '配对连接方式' },
632
842
  React.createElement('option', { value: 'auto' }, '自动选择 · 优先外网'),
633
843
  lanPairingUrl(status) || pairingRoute === 'lan'
634
844
  ? React.createElement('option', { value: 'lan', disabled: !lanPairingUrl(status) }, lanPairingUrl(status)
635
- ? '局域网直连 · ' + lanPairingUrl(status).replace(/^wss?:\/\//, '').split('/')[0]
845
+ ? '局域网直连'
636
846
  : '局域网直连 · 当前不可用')
637
847
  : null,
638
848
  status?.cloudflare?.enabled ? React.createElement('option', { value: 'cloudflare' }, 'Cloudflare 外网') : null,
639
849
  React.createElement('option', { value: 'custom' }, '手动输入地址')),
850
+ (pairingRoute === 'auto' || pairingRoute === 'cloudflare') && status?.cloudflare?.supported
851
+ ? React.createElement('span', { style: { fontSize: 12, lineHeight: 1.5, fontWeight: 500,
852
+ color: 'var(--dsw-alias-link, #4176e6)' } }, '外网使用 Cloudflare Tunnel 进行连接,请在下方进行设置。') : null)),
640
853
  React.createElement('label', { style: styles.label }, 'WebSocket 地址'),
641
854
  React.createElement('input', { style: styles.input, value: publicUrl, onChange: (event) => { choosePairingRoute('custom'); setPublicUrl(event.target.value) }, placeholder: 'ws://192.168.1.10:3081/ws/mobile', spellCheck: false }),
642
855
  status && status.lan && status.lan.enabled
643
- ? React.createElement('div', { style: { ...styles.muted, margin: '-7px 0 10px', color: status.lan.error ? colors.danger : colors.muted } },
856
+ ? React.createElement('div', { style: { ...styles.muted, margin: '-7px 0 10px', fontWeight: 500, color: status.lan.error ? colors.danger : status.lan.listening ? colors.success : 'var(--dsw-alias-link, #4176e6)' } },
644
857
  status.lan.error
645
858
  ? `局域网监听失败:${status.lan.error}`
646
859
  : status.lan.listening
647
860
  ? `局域网入口已开启${status.lan.urls && status.lan.urls.length ? `:${status.lan.urls.join('、')}` : `,端口 ${status.lan.port}`}`
648
861
  : '局域网入口启动中…')
649
862
  : null,
650
- React.createElement('label', { style: styles.label }, '设备名称'),
651
- React.createElement('input', { style: styles.input, value: deviceName, onChange: (event) => setDeviceName(event.target.value), maxLength: 80, placeholder: 'iPhone' }),
652
863
  React.createElement('button', {
653
864
  style: { ...styles.button, ...styles.primary, opacity: busy || !(status && status.gatewayEnabled) || !publicUrl.trim() ? .65 : 1 },
654
865
  disabled: busy || !(status && status.gatewayEnabled) || !publicUrl.trim(),
655
866
  onClick: pair,
656
867
  }, busy ? '正在生成…' : !(status && status.gatewayEnabled) ? '请先开启移动网关' : !publicUrl.trim() ? '等待连接地址' : '生成配对二维码'),
657
- React.createElement('p', { style: { ...styles.muted, margin: '10px 0 0' } }, '同一局域网选择“局域网直连”,无需关闭 Cloudflare Tunnel。局域网使用私有地址的 ws://;公网地址必须使用 wss://。配对码只能使用一次,并在 5 分钟内过期。')),
658
- qr ? React.createElement('section', { style: { ...styles.card, textAlign: 'center' } },
659
- React.createElement('strong', null, '使用 iOS 客户端扫码'),
660
- React.createElement('img', { style: styles.qr, src: `data:image/svg+xml;charset=utf-8,${encodeURIComponent(qr.svg)}`, alt: '一次性设备配对二维码' }),
661
- React.createElement('label', { style: { ...styles.label, textAlign: 'left' } }, 'Base64URL 配对字符串'),
662
- React.createElement('textarea', { style: styles.pairingText, value: qr.qrPayload, readOnly: true, spellCheck: false, onFocus: (event) => event.target.select() }),
663
- React.createElement('button', { style: { ...styles.button, width: '100%', marginBottom: 8, borderColor: pairingTokenCopied ? colors.success : colors.border, color: pairingTokenCopied ? colors.success : colors.text }, onClick: copyPairingText }, pairingTokenCopied ? '✓ 已复制' : '复制配对 Token'),
664
- pairingTokenCopied ? React.createElement('div', { style: { ...styles.muted, color: colors.success, marginBottom: 8 } }, '已复制到剪贴板') : null,
665
- React.createElement('div', { style: styles.muted }, `有效期至 ${new Date(qr.pairing.expiresAt).toLocaleTimeString()};扫码成功后此二维码立即失效。`)) : null,
868
+ React.createElement('p', { style: { ...styles.muted, margin: '10px 0 0' } }, '配对码仅可使用一次,5 分钟内有效。')),
869
+ (pairingRoute === 'auto' || pairingRoute === 'cloudflare') && status?.cloudflare?.supported
870
+ ? React.createElement('section', { className: 'mgw-cloudflare-block', style: { ...styles.card, background: colors.card, border: 0, borderRadius: 16, padding: 18, marginTop: 16 } },
871
+ React.createElement('h3', { className: 'mgw-section-title' }, 'Cloudflare Tunnel · 外网接入'),
872
+ React.createElement('p', { style: styles.muted }, '首次开启自动准备 Tunnel 组件。'),
873
+ React.createElement('div', { className: 'mgw-setting-row' },
874
+ React.createElement('span', { className: 'mgw-setting-label' }, '接入方式'),
875
+ React.createElement(GatewaySelect, { style: styles.input, value: cloudflareMode, 'aria-label': 'Cloudflare 接入方式', onChange: (event) => {
876
+ const mode = event.target.value
877
+ setCloudflareMode(mode)
878
+ if (status.cloudflare.enabled && mode !== status.cloudflare.mode &&
879
+ (mode === 'quick' || (status.cloudflare.configured && cloudflareHost === status.cloudflare.hostname && !cloudflareToken))) {
880
+ void configureCloudflare(true, mode)
881
+ }
882
+ }, disabled: cloudflareBusy },
883
+ React.createElement('option', { value: 'quick' }, 'Quick Tunnel · 无需域名'),
884
+ React.createElement('option', { value: 'named' }, '命名 Tunnel · 固定域名'))),
885
+ cloudflareMode === 'quick'
886
+ ? React.createElement('p', { style: styles.muted }, '临时地址可能变化,变化后需重新扫码。')
887
+ : React.createElement(React.Fragment, null,
888
+ React.createElement('p', { style: styles.muted }, '先在 Cloudflare 创建命名 Tunnel,把公开域名的 Service URL 设为 ', React.createElement('code', null, `http://127.0.0.1:${status.cloudflare.port}`), '。Tunnel Token 仅授权连接,不能自动创建公开主机名或 DNS;还需确认域名存在指向 Tunnel UUID.cfargotunnel.com 的已代理 CNAME 记录。'),
889
+ React.createElement('label', { style: styles.label }, '公开域名'),
890
+ React.createElement('input', { style: styles.input, value: cloudflareHost, onChange: (event) => setCloudflareHost(event.target.value.trim()), placeholder: 'gateway.example.com', spellCheck: false }),
891
+ React.createElement('label', { style: styles.label }, 'Tunnel Token'),
892
+ React.createElement('input', { style: styles.input, type: 'password', value: cloudflareToken, onChange: (event) => setCloudflareToken(event.target.value.trim()), placeholder: status.cloudflare.configured ? '已保存;留空则保持原 Token' : '粘贴 eyJ… Token', autoComplete: 'off', spellCheck: false })),
893
+ (!status.cloudflare.enabled || (cloudflareMode === 'named' &&
894
+ (status.cloudflare.mode !== 'named' || cloudflareHost !== status.cloudflare.hostname || !!cloudflareToken)))
895
+ ? React.createElement('button', {
896
+ style: { ...styles.button, ...styles.primary, opacity: cloudflareBusy || (cloudflareMode === 'named' && (!cloudflareHost || (!cloudflareToken && !status.cloudflare.configured))) ? .65 : 1 },
897
+ disabled: cloudflareBusy || (cloudflareMode === 'named' && (!cloudflareHost || (!cloudflareToken && !status.cloudflare.configured))),
898
+ onClick: () => configureCloudflare(true),
899
+ }, cloudflareBusy ? '正在处理…' : status.cloudflare.enabled ? '保存并连接' : cloudflareMode === 'quick' ? '一键开启 Quick Tunnel' : '开启命名 Tunnel') : null,
900
+ status.cloudflare.enabled
901
+ ? React.createElement(React.Fragment, null,
902
+ React.createElement('button', { style: { ...styles.button, width: '100%', marginTop: 8 }, disabled: cloudflareBusy || status.cloudflare.state !== 'online', onClick: checkCloudflare }, '检测公网 DNS / TLS / WebSocket'),
903
+ React.createElement('button', { style: { ...styles.button, width: '100%', marginTop: 8 }, disabled: cloudflareBusy || !status.gatewayEnabled, onClick: restartCloudflare }, '重启 Tunnel'),
904
+ React.createElement('button', { style: { ...styles.button, width: '100%', marginTop: 8 }, disabled: cloudflareBusy, onClick: () => configureCloudflare(false) }, '关闭 Cloudflare Tunnel'),
905
+ React.createElement('p', { style: styles.muted }, '重启会短暂断开连接。'))
906
+ : null,
907
+ React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere', fontWeight: 500, color: status.cloudflare.error ? colors.danger : status.cloudflare.state === 'online' ? colors.success : ['starting', 'preparing', 'connecting'].includes(status.cloudflare.state) ? 'var(--dsw-alias-link, #4176e6)' : colors.muted } },
908
+ status.cloudflare.error || ({ disabled: '未开启', starting: '正在启动本机入口…', preparing: '正在准备 cloudflared(首次需下载)…', connecting: '正在连接 Cloudflare…', online: '隧道传输已连接(不等于公网入口可达)', 'waiting-for-gateway': '等待移动网关开启' }[status.cloudflare.state] || status.cloudflare.state),
909
+ status.cloudflare.publicUrl ? ` · ${status.cloudflare.publicUrl}` : ''),
910
+ status.cloudflare.enabled && status.cloudflare.endpointHealth
911
+ ? React.createElement('p', { style: { ...styles.muted, overflowWrap: 'anywhere', fontWeight: 500, color: status.cloudflare.endpointHealth.state === 'reachable' ? colors.success : status.cloudflare.endpointHealth.state === 'checking' ? 'var(--dsw-alias-link, #4176e6)' : status.cloudflare.endpointHealth.state === 'unchecked' ? colors.muted : colors.danger } },
912
+ status.cloudflare.endpointHealth.message,
913
+ status.cloudflare.endpointHealth.checkedAt ? `(检测时间:${status.cloudflare.endpointHealth.checkedAt})` : '')
914
+ : null)
915
+ : null,
916
+ status && status.platform === 'linux' ? React.createElement('section', { style: styles.card },
917
+ React.createElement('h3', { className: 'mgw-section-title' }, '公网接入'),
918
+ publicSetup === null
919
+ ? React.createElement('p', { style: styles.muted }, '正在检查系统组件…')
920
+ : publicSetup.installed
921
+ ? React.createElement(React.Fragment, null,
922
+ React.createElement('div', { style: { ...styles.muted, margin: '5px 0 12px' } }, publicSetup.configured
923
+ ? publicSetup.backendPort && status && status.webPort && publicSetup.backendPort !== status.webPort
924
+ ? `端口需要更新:Nginx 当前为 ${publicSetup.backendPort},DSH 当前为 ${status.webPort}`
925
+ : `已配置${publicSetup.backendPort ? `,Nginx 转发至 DSH 端口 ${publicSetup.backendPort}` : ''}`
926
+ : `Helper 已就绪,将自动使用当前 DSH 端口${status && status.webPort ? ` ${status.webPort}` : ''}`),
927
+ React.createElement('label', { style: styles.label }, '服务器公网 IPv4'),
928
+ React.createElement('input', {
929
+ style: styles.input,
930
+ value: publicIp,
931
+ onChange: (event) => setPublicIp(event.target.value.trim()),
932
+ placeholder: '203.0.113.10',
933
+ inputMode: 'decimal',
934
+ spellCheck: false,
935
+ }),
936
+ React.createElement('button', {
937
+ style: { ...styles.button, ...styles.primary, opacity: publicSetupBusy || !publicIp ? .65 : 1 },
938
+ disabled: publicSetupBusy || !publicIp,
939
+ onClick: configurePublicAccess,
940
+ }, publicSetupBusy ? '正在配置 Nginx 与证书…' : publicSetup.configured ? '更新公网配置' : '配置公网接入'),
941
+ publicSetup.publicUrl
942
+ ? React.createElement('div', { style: { ...styles.muted, marginTop: 9, overflowWrap: 'anywhere' } }, publicSetup.publicUrl)
943
+ : null)
944
+ : React.createElement(React.Fragment, null,
945
+ React.createElement('p', { style: { ...styles.muted, marginBottom: 8 } }, '尚未安装系统 Helper。请在服务器执行一次初始化:'),
946
+ React.createElement('code', { style: { display: 'block', fontSize: 10, lineHeight: 1.5, overflowWrap: 'anywhere', userSelect: 'all' } }, 'npx --yes dsh-plugin-mobile-gateway@latest init'))) : null,
947
+
948
+ ),
949
+ React.createElement('div', { hidden: activeSection !== 'devices' },
666
950
  React.createElement('section', { style: styles.card },
667
951
  React.createElement('div', { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' } },
668
- React.createElement('strong', null, '可信设备'),
952
+ React.createElement('h3', { className: 'mgw-section-title', style: { marginBottom: 0 } }, '可信设备'),
669
953
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 10 } },
670
954
  deviceNotice || refreshNotice ? React.createElement('span', {
671
955
  style: { ...styles.muted, color: deviceNoticeError ? colors.danger : colors.success },
@@ -679,12 +963,22 @@ window.__ModuleLoader__.load({
679
963
  ? React.createElement('p', { style: styles.muted }, '加载中…')
680
964
  : devices.length === 0
681
965
  ? React.createElement('p', { style: styles.muted }, '暂无已配对设备。')
682
- : devices.map((device) => React.createElement(DeviceRow, {
966
+ : React.createElement(React.Fragment, null,
967
+ visibleDevices.length === 0 ? React.createElement('p', { style: styles.muted }, '当前没有在线设备。') : null,
968
+ visibleDevices.map((device) => React.createElement(DeviceRow, {
683
969
  key: device.id,
684
970
  device,
685
971
  onRevoke: revoke,
686
972
  revoking: revokingIds.has(device.id),
687
- }))),
973
+ })),
974
+ offlineDevices.length > 0 ? React.createElement('button', {
975
+ type: 'button',
976
+ style: { ...styles.button, marginTop: 10, color: colors.muted },
977
+ 'aria-expanded': showAllDevices,
978
+ onClick: () => setShowAllDevices((value) => !value),
979
+ }, showAllDevices ? '收起离线设备' : `展开其他已配对设备(${offlineDevices.length})`) : null)),
980
+ )),
981
+ ),
688
982
  status && status.version
689
983
  ? React.createElement('div', { style: styles.version, 'aria-label': `插件版本 ${status.version}` }, `v${status.version}`)
690
984
  : null))