NodePairingDialog.vue 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  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 {
  6. createPairing,
  7. pairedDevices,
  8. pairingStatus,
  9. revokePairedDevice,
  10. type NodeScope,
  11. type PairedDevice,
  12. type PairingSession,
  13. } from '@/api/pairing'
  14. const props = withDefaults(defineProps<{ open: boolean; insecureTransport?: boolean; userName?: string }>(), {
  15. insecureTransport: false,
  16. userName: '',
  17. })
  18. const emit = defineEmits<{ close: [] }>()
  19. const ALL_SCOPES: NodeScope[] = [
  20. 'agents.read', 'history.read', 'sessions.execute', 'groups.read', 'groups.execute',
  21. ]
  22. const pairing = ref<PairingSession>()
  23. const qrImage = ref('')
  24. const devices = ref<PairedDevice[]>([])
  25. const nodeID = ref('')
  26. const busy = ref(false)
  27. const error = ref('')
  28. const username = ref('')
  29. const password = ref('')
  30. const now = ref(Date.now())
  31. let timer: number | undefined
  32. const secondsRemaining = computed(() => pairing.value
  33. ? Math.max(0, Math.ceil((pairing.value.expiresAt - now.value) / 1_000))
  34. : 0)
  35. function stopPolling() {
  36. if (timer !== undefined) window.clearInterval(timer)
  37. timer = undefined
  38. }
  39. async function refreshDevices() {
  40. const response = await pairedDevices()
  41. devices.value = response.devices
  42. nodeID.value = response.nodeId
  43. }
  44. async function poll() {
  45. now.value = Date.now()
  46. const current = pairing.value
  47. if (!current || secondsRemaining.value <= 0) {
  48. stopPolling()
  49. return
  50. }
  51. try {
  52. const status = await pairingStatus(current.pairingId)
  53. if (status.state === 'claimed') {
  54. pairing.value = undefined
  55. qrImage.value = ''
  56. stopPolling()
  57. await refreshDevices()
  58. } else if (status.state === 'expired') {
  59. stopPolling()
  60. }
  61. } catch {
  62. // A transient poll failure must not discard a still-valid QR code.
  63. }
  64. }
  65. async function beginPairing() {
  66. busy.value = true
  67. error.value = ''
  68. stopPolling()
  69. try {
  70. const created = await createPairing(
  71. ALL_SCOPES,
  72. username.value.trim(),
  73. password.value,
  74. )
  75. password.value = ''
  76. pairing.value = created
  77. now.value = Date.now()
  78. qrImage.value = await QRCode.toDataURL(created.qrPayload, {
  79. width: 296,
  80. margin: 2,
  81. errorCorrectionLevel: 'M',
  82. color: { dark: '#191918', light: '#ffffff' },
  83. })
  84. timer = window.setInterval(() => { void poll() }, 1_000)
  85. } catch (cause) {
  86. error.value = cause instanceof Error ? cause.message : '无法创建配对二维码'
  87. } finally {
  88. busy.value = false
  89. }
  90. }
  91. async function revoke(device: PairedDevice) {
  92. if (!window.confirm(`撤销“${device.name}”的 Hermes 访问权限?`)) return
  93. busy.value = true
  94. error.value = ''
  95. try {
  96. await revokePairedDevice(device.id)
  97. await refreshDevices()
  98. } catch (cause) {
  99. error.value = cause instanceof Error ? cause.message : '撤销设备失败'
  100. } finally {
  101. busy.value = false
  102. }
  103. }
  104. watch(() => props.open, open => {
  105. stopPolling()
  106. pairing.value = undefined
  107. qrImage.value = ''
  108. error.value = ''
  109. password.value = ''
  110. if (open) {
  111. username.value = props.userName
  112. busy.value = true
  113. void refreshDevices()
  114. .catch(cause => { error.value = cause instanceof Error ? cause.message : '读取配对设备失败' })
  115. .finally(() => { busy.value = false })
  116. }
  117. })
  118. onBeforeUnmount(stopPolling)
  119. </script>
  120. <template>
  121. <Teleport to="body">
  122. <Transition name="pairing-fade">
  123. <div v-if="open" class="pairing-layer" role="presentation" @mousedown.self="emit('close')">
  124. <section class="pairing-dialog" role="dialog" aria-modal="true" aria-labelledby="pairing-title">
  125. <header>
  126. <div><small>HERMES NODE</small><h2 id="pairing-title">手机与节点</h2></div>
  127. <button class="icon-button" type="button" aria-label="关闭手机与节点" @click="emit('close')"><AppIcon name="close" /></button>
  128. </header>
  129. <p class="pairing-intro">扫描一次即可在 iOS 中使用这台 Hermes 的 Bots、聊天历史和团队 Agent。</p>
  130. <p v-if="insecureTransport" class="pairing-warning"><AppIcon name="alert" :size="15" />当前是局域网 HTTP,二维码免密配对仅适用于可信网络。正式使用请配置 HTTPS/WSS 或 Tailscale。</p>
  131. <p v-if="error" class="pairing-error">{{ error }}</p>
  132. <div v-if="!pairing" class="pairing-credentials">
  133. <label><span>Hermes 用户名</span><input v-model="username" autocomplete="username" /></label>
  134. <label><span>密码</span><input v-model="password" type="password" autocomplete="current-password" @keydown.enter="beginPairing" /></label>
  135. <small>密码只用于向 Hermes 创建一份独立的设备会话,不写入二维码,也不会由夭夭 Web 保存。</small>
  136. </div>
  137. <section v-if="pairing" class="qr-card">
  138. <img v-if="qrImage" :src="qrImage" alt="添加此 Hermes 节点的二维码" />
  139. <strong>用夭夭 iOS 扫描</strong>
  140. <span v-if="secondsRemaining">二维码将在 {{ secondsRemaining }} 秒后失效</span>
  141. <span v-else>二维码已失效</span>
  142. <button v-if="!secondsRemaining" class="quiet-button" type="button" @click="beginPairing">重新生成</button>
  143. </section>
  144. <button v-else class="solid-button create-pairing" type="button" :disabled="busy || !username.trim() || !password" @click="beginPairing">
  145. <AppIcon name="users" :size="17" />{{ busy ? '正在准备…' : '生成手机配对二维码' }}
  146. </button>
  147. <section class="device-section">
  148. <div class="device-heading"><strong>已授权设备</strong><small v-if="nodeID">节点 {{ nodeID.slice(0, 8) }}</small></div>
  149. <p v-if="!busy && !devices.length" class="device-empty">还没有已授权设备。</p>
  150. <div v-for="device in devices" :key="device.id" class="device-row">
  151. <span><b>{{ device.name }}</b><small>{{ device.scopes.includes('history.read') ? 'Bots、历史与团队' : '受限访问' }}</small></span>
  152. <button type="button" :disabled="busy" @click="revoke(device)">撤销</button>
  153. </div>
  154. </section>
  155. </section>
  156. </div>
  157. </Transition>
  158. </Teleport>
  159. </template>
  160. <style scoped>
  161. .pairing-layer { position: fixed; z-index: 260; inset: 0; display: grid; place-items: center; padding: 18px; background: var(--scrim); backdrop-filter: blur(6px); }
  162. .pairing-dialog { width: min(470px, 100%); max-height: min(760px, calc(100vh - 36px)); overflow-y: auto; padding: 18px; border: 1px solid var(--line); border-radius: 18px; background: var(--surface-raised); box-shadow: var(--shadow-float); }
  163. header { display: flex; align-items: flex-start; justify-content: space-between; } header small { color: var(--text-muted); font-size: 9px; letter-spacing: .08em; } h2 { margin: 3px 0 0; font-size: 19px; letter-spacing: -.03em; }
  164. .pairing-intro { margin: 17px 0 14px; color: var(--text-secondary); font-size: 11px; line-height: 1.65; }
  165. .pairing-warning, .pairing-error { margin: 0 0 13px; padding: 10px; border-radius: 10px; font-size: 10px; line-height: 1.55; }.pairing-warning { display: flex; gap: 7px; background: color-mix(in srgb, var(--warning, #bd7611) 10%, transparent); color: var(--text-secondary); }.pairing-error { background: color-mix(in srgb, var(--danger) 9%, transparent); color: var(--danger); }
  166. .create-pairing { width: 100%; min-height: 43px; gap: 8px; }
  167. .pairing-credentials { display: grid; gap: 10px; margin-bottom: 13px; }.pairing-credentials label { display: grid; gap: 5px; color: var(--text-secondary); font-size: 10px; }.pairing-credentials input { min-height: 38px; padding: 0 10px; border: 1px solid var(--line); border-radius: 10px; outline: 0; background: var(--surface-soft); color: var(--text-primary); }.pairing-credentials input:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }.pairing-credentials small { color: var(--text-muted); font-size: 9px; line-height: 1.5; }
  168. .qr-card { display: grid; justify-items: center; gap: 7px; padding: 14px; border: 1px solid var(--line); border-radius: 14px; background: #fff; color: #191918; }.qr-card img { width: min(296px, 100%); aspect-ratio: 1; }.qr-card strong { font-size: 12px; }.qr-card span { color: #686865; font-size: 10px; }.qr-card .quiet-button { color: #191918; }
  169. .device-section { margin-top: 19px; padding-top: 15px; border-top: 1px solid var(--line); }.device-heading { display: flex; justify-content: space-between; margin-bottom: 8px; }.device-heading strong { font-size: 11px; }.device-heading small, .device-empty { color: var(--text-muted); font-size: 9px; }.device-empty { margin: 12px 1px; }
  170. .device-row { display: flex; align-items: center; gap: 12px; min-height: 47px; padding: 6px 2px; border-bottom: 1px solid var(--line); }.device-row span { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }.device-row b { overflow: hidden; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }.device-row small { color: var(--text-muted); font-size: 9px; }.device-row button { border: 0; background: transparent; color: var(--danger); font-size: 10px; cursor: pointer; }
  171. .pairing-fade-enter-active, .pairing-fade-leave-active { transition: opacity 140ms ease; }.pairing-fade-enter-active .pairing-dialog, .pairing-fade-leave-active .pairing-dialog { transition: transform 170ms var(--ease-out); }.pairing-fade-enter-from, .pairing-fade-leave-to { opacity: 0; }.pairing-fade-enter-from .pairing-dialog, .pairing-fade-leave-to .pairing-dialog { transform: translateY(8px) scale(.985); }
  172. @media (max-width: 540px) { .pairing-layer { place-items: end center; padding: 0; }.pairing-dialog { width: 100%; max-height: calc(100vh - 12px); border-radius: 19px 19px 0 0; padding-bottom: max(18px, env(safe-area-inset-bottom)); } }
  173. </style>