HostToolsPanel.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. <script setup lang="ts">
  2. import { computed, onMounted, ref } from 'vue'
  3. import { apiRequest } from '@/api/client'
  4. import type { DesktopHostSummary } from '@shared/desktopHost'
  5. const emit = defineEmits<{ 'dirty-change': [value: boolean] }>()
  6. const approvalPolicy = ref<'ask'|'allow'|'deny'>('ask')
  7. const scriptMachine = ref(true)
  8. const serverComputer = ref(true)
  9. const vm = ref(true)
  10. const cloud = ref(true)
  11. const loading = ref(false), saving = ref(false), error = ref(''), saved = ref(false)
  12. type ComputerRow = { id: string; label: string; name: string; online?: boolean; createdAt?: number }
  13. const hosts = ref<ComputerRow[]>([])
  14. const drafts = ref<Record<string, string>>({})
  15. const nameError = ref(''), nameStatus = ref(''), namesLoading = ref(false), nameBusy = ref('')
  16. const pendingRemoval = ref('')
  17. const namesDisabled = computed(() => namesLoading.value || !!nameBusy.value)
  18. const hasSameNames = computed(() => {
  19. const labels = hosts.value.filter(host => host.id !== 'local').map(host => host.label)
  20. return new Set(labels).size !== labels.length
  21. })
  22. function pairedAt(value: number) {
  23. return new Date(value).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
  24. }
  25. async function loadNames() {
  26. if (namesLoading.value) return
  27. namesLoading.value = true
  28. nameError.value = ''
  29. try {
  30. const value = await apiRequest<{localName?: string; hosts: DesktopHostSummary[]}>('/api/app/admin/desktop-hosts')
  31. const rows: ComputerRow[] = [{ id: 'local', label: '服务器 · 夭夭所在的电脑', name: value.localName || '' }]
  32. for (const host of value.hosts) {
  33. if (!host.enabled) continue
  34. rows.push({ id: host.id, label: host.hostName || host.name, name: host.displayName || '', online: host.online, createdAt: host.createdAt })
  35. }
  36. const edited = new Set(hosts.value.filter(host => drafts.value[host.id] !== host.name).map(host => host.id))
  37. drafts.value = Object.fromEntries(rows.map(row => [row.id, edited.has(row.id) ? drafts.value[row.id] : row.name]))
  38. hosts.value = rows
  39. } catch (cause) { nameError.value = cause instanceof Error ? cause.message : '无法读取电脑名单' }
  40. finally { namesLoading.value = false }
  41. }
  42. async function saveName(id: string) {
  43. if (namesDisabled.value) return
  44. nameBusy.value = id; nameError.value = ''; nameStatus.value = ''
  45. const name = drafts.value[id] || ''
  46. try {
  47. await apiRequest(`/api/app/admin/desktop-hosts/${encodeURIComponent(id)}/name`, { method: 'PUT', body: { name } })
  48. const host = hosts.value.find(row => row.id === id)
  49. if (host) host.name = name
  50. nameStatus.value = '名字已保存'
  51. await loadNames()
  52. } catch (cause) { nameError.value = cause instanceof Error ? cause.message : '改名失败' }
  53. finally { nameBusy.value = '' }
  54. }
  55. async function removeHost(id: string) {
  56. if (namesDisabled.value || id === 'local' || pendingRemoval.value !== id) return
  57. nameBusy.value = id; nameError.value = ''; nameStatus.value = ''
  58. try {
  59. await apiRequest(`/api/app/admin/desktop-hosts/${encodeURIComponent(id)}`, { method: 'DELETE' })
  60. hosts.value = hosts.value.filter(host => host.id !== id)
  61. delete drafts.value[id]
  62. pendingRemoval.value = ''
  63. nameStatus.value = '这条配对记录已停用'
  64. } catch (cause) { nameError.value = cause instanceof Error ? cause.message : '停用失败,请重试' }
  65. finally { nameBusy.value = '' }
  66. }
  67. type Settings = { approvalPolicy?: 'ask'|'allow'|'deny'; scriptMachine: boolean; serverComputer: boolean; vm: boolean; cloud: boolean }
  68. function apply(value: Settings) {
  69. approvalPolicy.value = value.approvalPolicy ?? 'ask'
  70. scriptMachine.value = value.scriptMachine
  71. serverComputer.value = value.serverComputer
  72. vm.value = value.vm
  73. cloud.value = value.cloud
  74. }
  75. function payload(): Settings {
  76. return { approvalPolicy: approvalPolicy.value, scriptMachine: scriptMachine.value, serverComputer: serverComputer.value, vm: vm.value, cloud: cloud.value }
  77. }
  78. async function load() {
  79. loading.value = true; error.value = ''
  80. try {
  81. apply(await apiRequest<Settings>('/api/app/settings/host-tools'))
  82. emit('dirty-change', false)
  83. } catch (cause) { error.value = cause instanceof Error ? cause.message : '无法读取电脑设置' }
  84. finally { loading.value = false }
  85. }
  86. function changed() { saved.value = false; emit('dirty-change', true) }
  87. async function save() {
  88. saving.value = true; error.value = ''; saved.value = false
  89. try {
  90. apply(await apiRequest<Settings>('/api/app/settings/host-tools', { method: 'PUT', body: payload() }))
  91. saved.value = true; emit('dirty-change', false)
  92. } catch (cause) { error.value = cause instanceof Error ? cause.message : '保存失败,请重试' }
  93. finally { saving.value = false }
  94. }
  95. onMounted(() => { void load(); void loadNames() })
  96. </script>
  97. <template>
  98. <section class="host-tools" aria-label="电脑与服务端工具">
  99. <p>这些开关对所有机器人生效,不再按单个机器人勾选。所有 Bot 共用电脑权限和审批策略。设备是否在线、是否获得系统授权会单独显示。</p>
  100. <form @submit.prevent="save" @change="changed">
  101. <label class="toggle"><input v-model="scriptMachine" type="checkbox" :disabled="loading || saving" /><span><strong>脚本机</strong><small>客户连接的 Mac。机器人按名称点名:文件走文件工具,命令走 shell,只有看窗口或点按时才截图。</small></span></label>
  102. <label class="toggle"><input v-model="serverComputer" type="checkbox" :disabled="loading || saving" /><span><strong>服务器</strong><small>夭夭正在运行的这台电脑。文件走文件工具,命令走 shell;只有看窗口或点按时才截图操作。</small></span></label>
  103. <label class="toggle"><input v-model="vm" type="checkbox" :disabled="loading || saving" /><span><strong>虚拟环境</strong><small>服务端上的隔离桌面。聊天留在夭夭,操作派到虚拟机里。</small></span></label>
  104. <label class="toggle"><input v-model="cloud" type="checkbox" :disabled="loading || saving" /><span><strong>云虚拟机</strong><small>共享的 Grok Bot 电脑。先看真实桌面,再点击、输入、按键或滚动。</small></span></label>
  105. <label>所有 Bot 的审批策略<select v-model="approvalPolicy" :disabled="loading || saving"><option value="ask">询问我</option><option value="allow">自动允许</option><option value="deny">自动拒绝</option></select><small>统一用于新的工具审批请求;已等待的请求仍由你答复。Hermes 原生终端和文件始终操作服务器。</small></label>
  106. <p v-if="error" class="error" role="alert">{{ error }}</p>
  107. <p v-if="saved" role="status">已保存。所有 Bot 的新操作将使用统一设置。</p>
  108. <div class="actions"><button type="button" :disabled="loading || saving" @click="load">重新读取</button><button type="submit" class="save" :disabled="loading || saving">{{ saving ? '保存中…' : loading ? '读取中…' : '保存设置' }}</button></div>
  109. </form>
  110. <div class="names">
  111. <div class="names-heading"><strong>电脑名称与配对记录</strong><button type="button" :disabled="namesDisabled" @click="loadNames">{{ namesLoading ? '刷新中…' : '刷新电脑列表' }}</button></div>
  112. <p>每台电脑可以改名,和机器人点名用的是同一套名字。服务器留空时显示「服务器」,脚本机留空时用配对时的主机名。「本机」跟用户当前发消息所在的电脑走,不是固定某一台。</p>
  113. <p v-if="hasSameNames" class="same-names">有同名的配对记录。请结合连接状态、编号和配对时间区分,确认不再使用后可停用旧记录。</p>
  114. <p v-if="nameError" class="error" role="alert">{{ nameError }}</p>
  115. <p v-if="nameStatus" role="status">{{ nameStatus }}</p>
  116. <div v-for="host in hosts" :key="host.id" class="computer" :data-host-id="host.id">
  117. <div class="computer-heading"><label :for="`computer-name-${host.id}`">{{ host.label }}</label><span class="connection">{{ host.id === 'local' ? '当前服务器' : host.online ? '已连接' : '未连接' }}</span></div>
  118. <div v-if="host.id !== 'local'" class="computer-details">
  119. <small :title="host.id">编号 {{ host.id.slice(0, 8) }}</small>
  120. <small v-if="host.createdAt">配对于 {{ pairedAt(host.createdAt) }}</small>
  121. </div>
  122. <div class="name-editor"><input :id="`computer-name-${host.id}`" v-model="drafts[host.id]" :disabled="namesDisabled" maxlength="64" :placeholder="host.id === 'local' ? '服务器' : host.label" :aria-label="`名字 ${host.label}${host.id === 'local' ? '' : `,编号 ${host.id.slice(0, 8)}`}`" /><button type="button" :disabled="namesDisabled" @click="saveName(host.id)">保存名字</button><button v-if="host.id !== 'local'" type="button" class="remove-host" :disabled="namesDisabled" @click="pendingRemoval = host.id">停用</button></div>
  123. <div v-if="pendingRemoval === host.id" class="remove-confirmation" role="group" :aria-label="`停用编号 ${host.id.slice(0, 8)} 的配对记录`">
  124. <p>停用后,机器人将无法连接这条配对记录。需要在这台电脑重新登录或配对才能恢复。</p>
  125. <div class="actions"><button type="button" :disabled="namesDisabled" @click="pendingRemoval = ''">取消</button><button type="button" class="remove-host" :disabled="namesDisabled" @click="removeHost(host.id)">{{ nameBusy === host.id ? '停用中…' : '确认停用' }}</button></div>
  126. </div>
  127. </div>
  128. <p v-if="!hosts.length && !nameError && !namesLoading">还没有可改名的电脑。</p>
  129. </div>
  130. </section>
  131. </template>
  132. <style scoped>
  133. .host-tools{display:grid;gap:18px;max-width:680px;color:var(--text-primary);font-size:13px;line-height:1.7}.host-tools p{margin:0}.host-tools small{display:block;color:var(--text-secondary);font-size:12px}.host-tools form{display:grid;gap:14px}.toggle{display:flex;gap:12px;align-items:flex-start;cursor:pointer}.toggle input{width:20px;height:20px;margin-top:2px;accent-color:var(--accent)}.actions{display:flex;gap:10px;justify-content:flex-end}.actions button{min-height:44px;padding:8px 16px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.actions .save{background:var(--accent);color:var(--text-on-solid)}button:disabled{opacity:.55;cursor:default}.names{display:grid;gap:10px}.names input{min-height:44px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit}.names button{min-height:44px;padding:8px 12px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.error{color:var(--danger)}select:focus-visible,input:focus-visible,button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
  134. select{display:block;width:100%;min-height:44px;padding:8px 10px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:inherit;font:inherit}
  135. .names-heading,.computer-heading{display:flex;align-items:center;justify-content:space-between;gap:8px}.names .computer-heading label{min-width:0;overflow-wrap:anywhere;font-weight:600}.connection{flex-shrink:0;font-size:12px;color:var(--text-secondary)}.computer{display:grid;gap:8px;padding:12px;border:1px solid var(--line);border-radius:10px}.computer-details{display:flex;flex-wrap:wrap;gap:4px 16px}.name-editor{display:flex;flex-wrap:wrap;gap:8px}.name-editor input{min-width:0;flex:1 1 150px}.same-names,.remove-confirmation{padding:10px 12px;border-radius:8px;background:var(--surface-soft)}.remove-confirmation{display:grid;gap:8px}.names .remove-host{color:var(--danger)}.names button:hover:not(:disabled){background:var(--surface-soft)}.names button:active:not(:disabled){background:var(--settings-selected)}
  136. </style>