NodePairingPanel.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. <script setup lang="ts">
  2. import { computed, onBeforeUnmount, ref, watch } from 'vue'
  3. import QRCode from 'qrcode'
  4. import AppIcon from '@/components/common/AppIcon.vue'
  5. import { accountPairingStatus, createAccountPairing, type AccountPairingSession } from '@/api/accountPairing'
  6. import {
  7. createPairing,
  8. pairChildNode,
  9. pairedDevices,
  10. pairingStatus,
  11. revokePairedDevice,
  12. type NodeScope,
  13. type PairedDevice,
  14. type PairingSession,
  15. } from '@/api/pairing'
  16. const props = withDefaults(defineProps<{
  17. active?: boolean
  18. mode: 'account' | 'nodes'
  19. insecureTransport?: boolean
  20. userName?: string
  21. isAdmin?: boolean
  22. }>(), {
  23. active: true,
  24. insecureTransport: false,
  25. userName: '',
  26. isAdmin: false,
  27. })
  28. const emit = defineEmits<{ 'dirty-change': [dirty: boolean] }>()
  29. const ALL_SCOPES: NodeScope[] = [
  30. 'agents.read', 'history.read', 'sessions.execute', 'groups.read', 'groups.execute',
  31. ]
  32. const pairing = ref<PairingSession>()
  33. const accountPairing = ref<AccountPairingSession>()
  34. const qrImage = ref('')
  35. const accountQrImage = ref('')
  36. const accountPairingComplete = ref(false)
  37. const devices = ref<PairedDevice[]>([])
  38. const nodeID = ref('')
  39. const busy = ref(false)
  40. const error = ref('')
  41. const childCode = ref('')
  42. const childName = ref('')
  43. const scanVideo = ref<HTMLVideoElement>()
  44. const scanning = ref(false)
  45. const scanStarting = ref(false)
  46. const now = ref(Date.now())
  47. let timer: number | undefined
  48. let accountTimer: number | undefined
  49. let scanTimer: number | undefined
  50. let scanStream: MediaStream | undefined
  51. let lifecycleToken = 0
  52. let scanToken = 0
  53. let nodePolling = false
  54. let accountPolling = false
  55. type BarcodeDetectorLike = { detect(source: ImageBitmapSource): Promise<Array<{ rawValue?: string }>> }
  56. type BarcodeDetectorConstructor = new (options: { formats: string[] }) => BarcodeDetectorLike
  57. const secondsRemaining = computed(() => pairing.value
  58. ? Math.max(0, Math.ceil((pairing.value.expiresAt - now.value) / 1_000))
  59. : 0)
  60. const accountSecondsRemaining = computed(() => accountPairing.value
  61. ? Math.max(0, Math.ceil((accountPairing.value.expiresAt - now.value) / 1_000))
  62. : 0)
  63. const dirty = computed(() => props.mode === 'nodes' && Boolean(childName.value.trim() || childCode.value.trim()))
  64. watch(dirty, value => emit('dirty-change', value), { immediate: true })
  65. function stopPolling() {
  66. if (timer !== undefined) window.clearInterval(timer)
  67. timer = undefined
  68. nodePolling = false
  69. }
  70. function stopAccountPolling() {
  71. if (accountTimer !== undefined) window.clearInterval(accountTimer)
  72. accountTimer = undefined
  73. accountPolling = false
  74. }
  75. function stopScanning() {
  76. scanToken += 1
  77. scanStarting.value = false
  78. if (scanTimer !== undefined) window.clearInterval(scanTimer)
  79. scanTimer = undefined
  80. scanStream?.getTracks().forEach(track => track.stop())
  81. scanStream = undefined
  82. scanning.value = false
  83. }
  84. function resetTransientState() {
  85. lifecycleToken += 1
  86. stopPolling()
  87. stopAccountPolling()
  88. stopScanning()
  89. pairing.value = undefined
  90. accountPairing.value = undefined
  91. qrImage.value = ''
  92. accountQrImage.value = ''
  93. accountPairingComplete.value = false
  94. childCode.value = ''
  95. childName.value = ''
  96. error.value = ''
  97. busy.value = false
  98. }
  99. function isCurrent(token: number, mode: 'account' | 'nodes'): boolean {
  100. return token === lifecycleToken && props.active && props.mode === mode
  101. }
  102. function isCurrentScan(token: number, lifecycle: number): boolean {
  103. return token === scanToken && isCurrent(lifecycle, 'nodes')
  104. }
  105. async function pollAccountPairing(token = lifecycleToken) {
  106. if (accountPolling || !isCurrent(token, 'account')) return
  107. accountPolling = true
  108. now.value = Date.now()
  109. const current = accountPairing.value
  110. if (!current || accountSecondsRemaining.value <= 0) { stopAccountPolling(); return }
  111. try {
  112. const status = await accountPairingStatus(current.pairingId)
  113. if (!isCurrent(token, 'account') || accountPairing.value?.pairingId !== current.pairingId) return
  114. if (status.state === 'claimed') {
  115. accountPairingComplete.value = true
  116. stopAccountPolling()
  117. } else if (status.state === 'expired') stopAccountPolling()
  118. } catch { /* Keep a still-valid QR code during transient polling failures. */ }
  119. finally { accountPolling = false }
  120. }
  121. async function beginAccountPairing() {
  122. if (busy.value || !props.active || props.mode !== 'account') return
  123. const token = lifecycleToken
  124. busy.value = true
  125. error.value = ''
  126. accountPairingComplete.value = false
  127. stopAccountPolling()
  128. try {
  129. const created = await createAccountPairing()
  130. if (!isCurrent(token, 'account')) return
  131. accountPairing.value = created
  132. now.value = Date.now()
  133. const image = await QRCode.toDataURL(created.qrPayload, {
  134. width: 296, margin: 2, errorCorrectionLevel: 'M', color: { dark: '#191918', light: '#ffffff' },
  135. })
  136. if (!isCurrent(token, 'account')) return
  137. accountQrImage.value = image
  138. accountTimer = window.setInterval(() => { void pollAccountPairing(token) }, 1_000)
  139. } catch (cause) {
  140. if (isCurrent(token, 'account')) error.value = cause instanceof Error ? cause.message : '无法创建手机登录二维码'
  141. } finally {
  142. if (isCurrent(token, 'account')) busy.value = false
  143. }
  144. }
  145. async function startScanning() {
  146. if (scanStarting.value || scanning.value || !props.active || props.mode !== 'nodes') return
  147. const lifecycle = lifecycleToken
  148. const Detector = (window as unknown as { BarcodeDetector?: BarcodeDetectorConstructor }).BarcodeDetector
  149. if (!Detector || !navigator.mediaDevices?.getUserMedia) {
  150. error.value = '当前浏览器不支持直接扫码,请粘贴配对码。'
  151. return
  152. }
  153. stopScanning()
  154. const token = scanToken
  155. scanStarting.value = true
  156. error.value = ''
  157. let ownedStream: MediaStream | undefined
  158. const releaseOwnedStream = () => {
  159. ownedStream?.getTracks().forEach(track => track.stop())
  160. if (scanStream === ownedStream) {
  161. scanStream = undefined
  162. scanning.value = false
  163. }
  164. }
  165. try {
  166. const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false })
  167. ownedStream = stream
  168. if (!isCurrentScan(token, lifecycle)) {
  169. releaseOwnedStream()
  170. return
  171. }
  172. scanStream = stream
  173. scanStarting.value = false
  174. scanning.value = true
  175. await new Promise(resolve => window.setTimeout(resolve, 0))
  176. if (!isCurrentScan(token, lifecycle)) { releaseOwnedStream(); return }
  177. if (scanVideo.value) { scanVideo.value.srcObject = stream; await scanVideo.value.play() }
  178. if (!isCurrentScan(token, lifecycle)) { releaseOwnedStream(); return }
  179. const detector = new Detector({ formats: ['qr_code'] })
  180. scanTimer = window.setInterval(() => {
  181. if (!isCurrentScan(token, lifecycle)) { releaseOwnedStream(); return }
  182. const video = scanVideo.value
  183. if (!video || video.readyState < 2) return
  184. void detector.detect(video).then(values => {
  185. const payload = values.find(value => value.rawValue?.startsWith('yaoyao://pair'))?.rawValue
  186. if (payload && isCurrentScan(token, lifecycle)) { childCode.value = payload; stopScanning() }
  187. }).catch(() => undefined)
  188. }, 350)
  189. } catch {
  190. if (isCurrentScan(token, lifecycle)) {
  191. error.value = '无法使用摄像头,请检查权限或直接粘贴配对码。'
  192. stopScanning()
  193. } else {
  194. releaseOwnedStream()
  195. }
  196. } finally {
  197. if (isCurrentScan(token, lifecycle)) scanStarting.value = false
  198. }
  199. }
  200. async function refreshDevices(token = lifecycleToken) {
  201. const response = await pairedDevices()
  202. if (!isCurrent(token, 'nodes')) return
  203. devices.value = response.devices
  204. nodeID.value = response.nodeId
  205. }
  206. async function poll(token = lifecycleToken) {
  207. if (nodePolling || !isCurrent(token, 'nodes')) return
  208. nodePolling = true
  209. now.value = Date.now()
  210. const current = pairing.value
  211. if (!current || secondsRemaining.value <= 0) { stopPolling(); return }
  212. try {
  213. const status = await pairingStatus(current.pairingId)
  214. if (!isCurrent(token, 'nodes') || pairing.value?.pairingId !== current.pairingId) return
  215. if (status.state === 'claimed') {
  216. pairing.value = undefined
  217. qrImage.value = ''
  218. stopPolling()
  219. await refreshDevices(token)
  220. } else if (status.state === 'expired') stopPolling()
  221. } catch { /* A transient poll failure must not discard a still-valid QR code. */ }
  222. finally { nodePolling = false }
  223. }
  224. async function beginPairing() {
  225. if (!props.isAdmin || busy.value || !props.active || props.mode !== 'nodes') return
  226. const token = lifecycleToken
  227. busy.value = true
  228. error.value = ''
  229. stopPolling()
  230. try {
  231. const created = await createPairing(ALL_SCOPES)
  232. if (!isCurrent(token, 'nodes')) return
  233. pairing.value = created
  234. now.value = Date.now()
  235. const image = await QRCode.toDataURL(created.qrPayload, {
  236. width: 296, margin: 2, errorCorrectionLevel: 'M', color: { dark: '#191918', light: '#ffffff' },
  237. })
  238. if (!isCurrent(token, 'nodes')) return
  239. qrImage.value = image
  240. timer = window.setInterval(() => { void poll(token) }, 1_000)
  241. } catch (cause) {
  242. if (isCurrent(token, 'nodes')) error.value = cause instanceof Error ? cause.message : '无法创建配对二维码'
  243. } finally {
  244. if (isCurrent(token, 'nodes')) busy.value = false
  245. }
  246. }
  247. async function addChild() {
  248. if (!props.isAdmin || busy.value || !props.active || props.mode !== 'nodes') return
  249. const token = lifecycleToken
  250. busy.value = true
  251. error.value = ''
  252. try {
  253. await pairChildNode(childCode.value.trim(), childName.value.trim() || undefined)
  254. if (!isCurrent(token, 'nodes')) return
  255. childCode.value = ''
  256. childName.value = ''
  257. } catch (cause) {
  258. if (isCurrent(token, 'nodes')) error.value = cause instanceof Error ? cause.message : '添加子节点失败'
  259. } finally {
  260. if (isCurrent(token, 'nodes')) busy.value = false
  261. }
  262. }
  263. async function revoke(device: PairedDevice) {
  264. if (!props.isAdmin || !window.confirm(`撤销“${device.name}”的 Hermes 访问权限?`)) return
  265. if (busy.value || !props.active || props.mode !== 'nodes') return
  266. const token = lifecycleToken
  267. busy.value = true
  268. error.value = ''
  269. try {
  270. await revokePairedDevice(device.id)
  271. if (!isCurrent(token, 'nodes')) return
  272. await refreshDevices(token)
  273. } catch (cause) {
  274. if (isCurrent(token, 'nodes')) error.value = cause instanceof Error ? cause.message : '撤销设备失败'
  275. } finally {
  276. if (isCurrent(token, 'nodes')) busy.value = false
  277. }
  278. }
  279. watch(() => [props.active, props.mode] as const, ([active, mode]) => {
  280. resetTransientState()
  281. if (!active) return
  282. if (mode === 'account') {
  283. void beginAccountPairing()
  284. return
  285. }
  286. if (!props.isAdmin) return
  287. const token = lifecycleToken
  288. busy.value = true
  289. void refreshDevices(token)
  290. .catch(cause => { if (isCurrent(token, 'nodes')) error.value = cause instanceof Error ? cause.message : '读取配对设备失败' })
  291. .finally(() => { if (isCurrent(token, 'nodes')) busy.value = false })
  292. }, { immediate: true })
  293. onBeforeUnmount(resetTransientState)
  294. </script>
  295. <template>
  296. <section class="pairing-panel" :aria-label="mode === 'account' ? '手机登录' : '节点与设备'">
  297. <p v-if="mode === 'account'" class="pairing-intro">手机扫描登录二维码后,会把这台 15300 保存为普通服务器账号;不会添加成子节点。</p>
  298. <p v-else class="pairing-intro">管理当前 15300 的直接子节点与已授权设备。账号登录设备不会在这里显示。</p>
  299. <p v-if="mode === 'account' && insecureTransport" class="pairing-warning"><AppIcon name="alert" :size="16" />当前是局域网 HTTP,二维码免密配对仅适用于可信网络。正式使用请配置 HTTPS/WSS 或 Tailscale。</p>
  300. <p v-if="error" class="pairing-error" role="alert">{{ error }}</p>
  301. <template v-if="mode === 'account'">
  302. <div class="device-heading"><strong>扫码登录并添加服务器</strong><small>{{ userName }}</small></div>
  303. <section v-if="accountPairing" class="qr-card">
  304. <img v-if="accountQrImage" :src="accountQrImage" alt="手机扫码登录并添加服务器" />
  305. <strong>{{ accountPairingComplete ? '手机已添加此服务器' : '在 iOS 登录页选择扫码' }}</strong>
  306. <span v-if="accountPairingComplete">该二维码已经失效</span>
  307. <span v-else-if="accountSecondsRemaining">二维码将在 {{ accountSecondsRemaining }} 秒后失效</span>
  308. <span v-else>二维码已失效</span>
  309. <button v-if="accountPairingComplete || !accountSecondsRemaining" class="quiet-button" type="button" @click="beginAccountPairing">重新生成</button>
  310. </section>
  311. <p v-else-if="busy" class="device-empty">正在生成登录二维码…</p>
  312. </template>
  313. <template v-else-if="isAdmin">
  314. <section class="device-section device-section--first">
  315. <div class="device-heading"><strong>授权为 15300 子节点</strong><small>管理员</small></div>
  316. <section v-if="pairing" class="qr-card">
  317. <img v-if="qrImage" :src="qrImage" alt="添加此 Hermes 节点的二维码" />
  318. <strong>仅用于子节点配对</strong>
  319. <span v-if="secondsRemaining">二维码将在 {{ secondsRemaining }} 秒后失效</span>
  320. <span v-else>二维码已失效</span>
  321. <button v-if="!secondsRemaining" class="quiet-button" type="button" @click="beginPairing">重新生成</button>
  322. </section>
  323. <button v-else class="solid-button create-pairing" type="button" :disabled="busy" @click="beginPairing"><AppIcon name="users" :size="17" />{{ busy ? '正在准备…' : '生成子节点配对二维码' }}</button>
  324. </section>
  325. <section class="device-section">
  326. <div class="device-heading"><strong>已授权设备</strong><small v-if="nodeID">节点 {{ nodeID.slice(0, 8) }}</small></div>
  327. <p v-if="!busy && !devices.length" class="device-empty">还没有已授权设备。</p>
  328. <div v-for="device in devices" :key="device.id" class="device-row">
  329. <span><b>{{ device.name }}</b><small>{{ device.scopes.includes('history.read') ? 'Bots、历史与团队' : '受限访问' }}</small></span>
  330. <button type="button" :disabled="busy" @click="revoke(device)">撤销</button>
  331. </div>
  332. </section>
  333. <section class="device-section child-section">
  334. <div class="device-heading"><strong>添加 15300 子节点</strong><small>只管理直接子节点</small></div>
  335. <label><span>节点名称(可选)</span><input v-model="childName" :disabled="busy" placeholder="例如:办公室 Mac" /></label>
  336. <label><span>子节点配对码</span><textarea v-model="childCode" rows="3" :disabled="busy" placeholder="粘贴子节点生成的 yaoyao://pair…" /></label>
  337. <video v-if="scanning" ref="scanVideo" class="scanner-video" muted playsinline />
  338. <div class="child-actions">
  339. <button class="quiet-button" type="button" :disabled="busy || scanStarting" @click="scanning ? stopScanning() : startScanning()">{{ scanStarting ? '正在打开摄像头…' : scanning ? '停止扫码' : '使用摄像头扫码' }}</button>
  340. <button class="solid-button" type="button" :disabled="busy || !childCode.trim()" @click="addChild">验证并添加子节点</button>
  341. </div>
  342. </section>
  343. </template>
  344. </section>
  345. </template>
  346. <style scoped>
  347. .pairing-panel { display: grid; gap: 18px; }
  348. .pairing-intro { margin: 0; max-width: 650px; color: var(--text-secondary); font-size: 14px; line-height: 1.65; }
  349. .pairing-warning,.pairing-error { margin: 0; padding: 12px 14px; border-radius: 10px; font-size: 13px; line-height: 1.55; }
  350. .pairing-warning { display: flex; gap: 8px; background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary); }
  351. .pairing-error { background: color-mix(in srgb, var(--danger) 9%, transparent); color: var(--danger); }
  352. .device-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
  353. .device-heading strong { font-size: 16px; }
  354. .device-heading small,.device-empty { color: var(--text-muted); font-size: 13px; }
  355. .device-empty { margin: 0; }
  356. .qr-card { display: grid; width: min(360px, 100%); justify-self: center; justify-items: center; gap: 9px; padding: 18px; border: 1px solid var(--line); border-radius: 14px; background: #fff; color: #191918; }
  357. .qr-card img { width: min(260px, 100%); aspect-ratio: 1; }
  358. .qr-card strong { font-size: 14px; }
  359. .qr-card span { color: #686865; font-size: 12px; }
  360. .device-section { display: grid; gap: 10px; padding-top: 20px; border-top: 1px solid var(--line); }
  361. .device-section--first { padding-top: 0; border-top: 0; }
  362. .device-row { display: flex; min-height: 54px; align-items: center; gap: 12px; padding: 8px 2px; border-bottom: 1px solid var(--line); }
  363. .device-row span { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 4px; }
  364. .device-row b { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
  365. .device-row small { color: var(--text-muted); font-size: 12px; }
  366. .device-row button { border: 0; background: transparent; color: var(--danger); font-size: 13px; cursor: pointer; }
  367. .child-section label { display: grid; gap: 6px; color: var(--text-secondary); font-size: 13px; font-weight: 600; }
  368. .child-section input,.child-section textarea { padding: 10px 12px; border: 1px solid var(--line); border-radius: 9px; outline: 0; background: var(--surface-soft); color: var(--text-primary); font: inherit; resize: vertical; }
  369. .child-section input:focus,.child-section textarea:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
  370. .scanner-video { width: 100%; max-height: 260px; border-radius: 11px; background: #111; object-fit: cover; }
  371. .child-actions { display: flex; justify-content: flex-end; gap: 10px; }
  372. .quiet-button,.solid-button { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 7px; padding: 0 14px; border: 0; border-radius: 9px; cursor: pointer; font: 600 13px var(--font-ui); }
  373. .quiet-button { border: 1px solid var(--line); background: var(--surface-raised); color: var(--text-secondary); }
  374. .solid-button { background: var(--accent); color: var(--text-on-solid); }
  375. .create-pairing { width: 100%; min-height: 44px; }
  376. button:disabled { cursor: not-allowed; opacity: .5; }
  377. @media (max-width: 700px) {
  378. .qr-card { width: 100%; box-sizing: border-box; }
  379. .child-actions { align-items: stretch; flex-direction: column; }
  380. }
  381. </style>