Преглед на файлове

修复设备编号并发重复并区分同名电脑配对记录

Samien преди 1 месец
родител
ревизия
4a16d50

+ 14 - 4
desktop/host-manager.mjs

@@ -1,4 +1,4 @@
-import {readFile,writeFile,rename,mkdir,rm} from 'node:fs/promises'
+import {readFile,writeFile,rename,mkdir,rm,link} from 'node:fs/promises'
 import {join} from 'node:path'
 import {randomUUID} from 'node:crypto'
 import {request as httpRequest} from 'node:http'
@@ -46,11 +46,21 @@ export class DesktopHostManager {
  publish(message){this.state=message;this.options.onState?.(message)}
  async installIdentity(){
   const path=join(this.options.home,'desktop-install-id')
-  try{const id=(await readFile(path,'utf8')).trim();if(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))return id}catch(error){if(error.code!=='ENOENT')throw error}
+  const readIdentity=async()=>{
+   const id=(await readFile(path,'utf8')).trim()
+   if(!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id))throw new Error('本机设备标识损坏,请恢复原设备标识后重试')
+   return id
+  }
+  try{return await readIdentity()}catch(error){if(error.code!=='ENOENT')throw error}
   const id=randomUUID()
   await mkdir(this.options.home,{recursive:true,mode:0o700})
-  await writeFile(path,id,{mode:0o600})
-  return id
+  const temporary=`${path}.${id}`
+  try{
+   // Publish a complete identity without overwriting a concurrent login's ID.
+   await writeFile(temporary,id,{flag:'wx',mode:0o600})
+   try{await link(temporary,path);return id}catch(error){if(error.code!=='EEXIST')throw error}
+   return await readIdentity()
+  }finally{await rm(temporary,{force:true})}
  }
  serial(work){const result=this.chain.catch(()=>{}).then(work);this.chain=result;return result}
  async read(){

+ 22 - 0
desktop/host-manager.test.mjs

@@ -41,6 +41,28 @@ async function fixture(config){
  return {manager,home,configPath,cores,handled}
 }
 
+test('concurrent first logins share one persistent installation identity',async()=>{
+ const {manager,home}=await fixture()
+ try{
+  const managers=Array.from({length:20},()=>new DesktopHostManager(manager.options))
+  const ids=await Promise.all(managers.map(item=>item.installIdentity()))
+  assert.equal(new Set(ids).size,1,'one installation must not register as multiple computers')
+  assert.equal(await readFile(join(home,'desktop-install-id'),'utf8'),ids[0])
+  await manager.forget()
+  assert.equal(await new DesktopHostManager(manager.options).installIdentity(),ids[0])
+  assert.equal((await stat(join(home,'desktop-install-id'))).mode&0o077,0)
+ }finally{await rm(home,{recursive:true,force:true})}
+})
+
+test('a damaged installation identity is not silently replaced by a new computer',async()=>{
+ const {manager,home}=await fixture()
+ try{
+  await writeFile(join(home,'desktop-install-id'),'damaged')
+  await assert.rejects(()=>manager.installIdentity(),/设备标识/)
+  assert.equal(await readFile(join(home,'desktop-install-id'),'utf8'),'damaged')
+ }finally{await rm(home,{recursive:true,force:true})}
+})
+
 test('imports an encrypted config, exchanges with the paired server and stops on revocation',async()=>{
  const token=randomBytes(32).toString('base64url'),hostId=randomUUID()
  const exchanges=[]

+ 20 - 0
docs/verification/2026-09-19-computer-records/README.md

@@ -0,0 +1,20 @@
+# 同名电脑配对记录修复
+
+电脑列表原先只显示主机名,无法区分不同的配对记录。现在显示连接状态、记录编号和配对时间,并支持刷新、按编号改名以及确认后停用指定记录。同名记录仍分别保留,避免把不同电脑误当成一台。
+
+桌面端首次生成安装标识时存在并发覆盖:20 个并发初始化在修复前返回 20 个不同编号。改成先写完整临时文件,再原子创建安装标识,修复后全部返回同一编号;退出配对、重新创建管理器后也沿用原编号。标识损坏时明确报错,不再静默生成新设备。
+
+验证:
+
+- `node --test desktop/host-manager.test.mjs`:6 项通过,包括并发初始化、持久化、损坏标识及连接撤销。
+- `NODE_OPTIONS=--no-experimental-webstorage npx vitest run tests/client/hostToolsPanel.test.ts tests/client/botSettingsDialog.test.ts tests/server/desktopHosts.test.ts`:14 项通过。
+- `npm run typecheck`、`npm run build` 通过。
+- Playwright 使用内存接口验证按编号改名、确认停用后另一条同名记录仍在,无页面异常。
+- 浅色桌面、深色桌面、375px 窄屏及 812px 横屏检查;修复了窄屏分类导航撑宽内容的问题,电脑卡片及操作按钮无横向溢出,按钮高度至少 44px。
+
+截图来自 `tests/fixtures/settings-design/index.html?view=computers` 的示例数据,编号、配对时间和连接状态均为模拟值,不能用来判断用户服务器上哪条记录需要停用。尚未读取线上两条记录的实际身份,也未修改线上配对数据。
+
+- [桌面浅色](desktop.png)
+- [桌面深色](dark.png)
+- [窄屏](narrow.png)
+- [横屏](landscape.png)

BIN
docs/verification/2026-09-19-computer-records/dark.png


BIN
docs/verification/2026-09-19-computer-records/desktop.png


BIN
docs/verification/2026-09-19-computer-records/landscape.png


BIN
docs/verification/2026-09-19-computer-records/narrow.png


+ 60 - 12
src/client/components/app/HostToolsPanel.vue

@@ -1,6 +1,7 @@
 <script setup lang="ts">
-import { onMounted, ref } from 'vue'
+import { computed, onMounted, ref } from 'vue'
 import { apiRequest } from '@/api/client'
+import type { DesktopHostSummary } from '@shared/desktopHost'
 
 const emit = defineEmits<{ 'dirty-change': [value: boolean] }>()
 const approvalPolicy = ref<'ask'|'allow'|'deny'>('ask')
@@ -9,28 +10,60 @@ const serverComputer = ref(true)
 const vm = ref(true)
 const cloud = ref(true)
 const loading = ref(false), saving = ref(false), error = ref(''), saved = ref(false)
-const hosts = ref<{id:string; label:string; name:string}[]>([])
+type ComputerRow = { id: string; label: string; name: string; online?: boolean; createdAt?: number }
+const hosts = ref<ComputerRow[]>([])
 const drafts = ref<Record<string, string>>({})
-const nameError = ref('')
+const nameError = ref(''), nameStatus = ref(''), namesLoading = ref(false), nameBusy = ref('')
+const pendingRemoval = ref('')
+const namesDisabled = computed(() => namesLoading.value || !!nameBusy.value)
+const hasSameNames = computed(() => {
+  const labels = hosts.value.filter(host => host.id !== 'local').map(host => host.label)
+  return new Set(labels).size !== labels.length
+})
+function pairedAt(value: number) {
+  return new Date(value).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+}
 async function loadNames() {
+  if (namesLoading.value) return
+  namesLoading.value = true
   nameError.value = ''
   try {
-    const value = await apiRequest<{localName?: string; hosts: {id:string; name:string; displayName?: string; enabled:boolean; hostName?: string}[]}>('/api/app/admin/desktop-hosts')
-    const rows = [{ id: 'local', label: '服务器 · 夭夭所在的电脑', name: value.localName || '' }]
+    const value = await apiRequest<{localName?: string; hosts: DesktopHostSummary[]}>('/api/app/admin/desktop-hosts')
+    const rows: ComputerRow[] = [{ id: 'local', label: '服务器 · 夭夭所在的电脑', name: value.localName || '' }]
     for (const host of value.hosts) {
       if (!host.enabled) continue
-      rows.push({ id: host.id, label: host.hostName || host.name, name: host.displayName || '' })
+      rows.push({ id: host.id, label: host.hostName || host.name, name: host.displayName || '', online: host.online, createdAt: host.createdAt })
     }
+    const edited = new Set(hosts.value.filter(host => drafts.value[host.id] !== host.name).map(host => host.id))
+    drafts.value = Object.fromEntries(rows.map(row => [row.id, edited.has(row.id) ? drafts.value[row.id] : row.name]))
     hosts.value = rows
-    drafts.value = Object.fromEntries(rows.map(row => [row.id, row.name]))
   } catch (cause) { nameError.value = cause instanceof Error ? cause.message : '无法读取电脑名单' }
+  finally { namesLoading.value = false }
 }
 async function saveName(id: string) {
-  nameError.value = ''
+  if (namesDisabled.value) return
+  nameBusy.value = id; nameError.value = ''; nameStatus.value = ''
+  const name = drafts.value[id] || ''
   try {
-    await apiRequest(`/api/app/admin/desktop-hosts/${encodeURIComponent(id)}/name`, { method: 'PUT', body: { name: drafts.value[id] || '' } })
+    await apiRequest(`/api/app/admin/desktop-hosts/${encodeURIComponent(id)}/name`, { method: 'PUT', body: { name } })
+    const host = hosts.value.find(row => row.id === id)
+    if (host) host.name = name
+    nameStatus.value = '名字已保存'
     await loadNames()
   } catch (cause) { nameError.value = cause instanceof Error ? cause.message : '改名失败' }
+  finally { nameBusy.value = '' }
+}
+async function removeHost(id: string) {
+  if (namesDisabled.value || id === 'local' || pendingRemoval.value !== id) return
+  nameBusy.value = id; nameError.value = ''; nameStatus.value = ''
+  try {
+    await apiRequest(`/api/app/admin/desktop-hosts/${encodeURIComponent(id)}`, { method: 'DELETE' })
+    hosts.value = hosts.value.filter(host => host.id !== id)
+    delete drafts.value[id]
+    pendingRemoval.value = ''
+    nameStatus.value = '这条配对记录已停用'
+  } catch (cause) { nameError.value = cause instanceof Error ? cause.message : '停用失败,请重试' }
+  finally { nameBusy.value = '' }
 }
 
 type Settings = { approvalPolicy?: 'ask'|'allow'|'deny'; scriptMachine: boolean; serverComputer: boolean; vm: boolean; cloud: boolean }
@@ -78,15 +111,30 @@ onMounted(() => { void load(); void loadNames() })
       <div class="actions"><button type="button" :disabled="loading || saving" @click="load">重新读取</button><button type="submit" class="save" :disabled="loading || saving">{{ saving ? '保存中…' : loading ? '读取中…' : '保存设置' }}</button></div>
     </form>
     <div class="names">
+      <div class="names-heading"><strong>电脑名称与配对记录</strong><button type="button" :disabled="namesDisabled" @click="loadNames">{{ namesLoading ? '刷新中…' : '刷新电脑列表' }}</button></div>
       <p>每台电脑可以改名,和机器人点名用的是同一套名字。服务器留空时显示「服务器」,脚本机留空时用配对时的主机名。「本机」跟用户当前发消息所在的电脑走,不是固定某一台。</p>
+      <p v-if="hasSameNames" class="same-names">有同名的配对记录。请结合连接状态、编号和配对时间区分,确认不再使用后可停用旧记录。</p>
       <p v-if="nameError" class="error" role="alert">{{ nameError }}</p>
-      <label v-for="host in hosts" :key="host.id"><span>{{ host.label }}</span><input v-model="drafts[host.id]" maxlength="64" :placeholder="host.id === 'local' ? '服务器' : host.label" :aria-label="`名字 ${host.label}`" /><button type="button" @click="saveName(host.id)">保存名字</button></label>
-      <p v-if="!hosts.length && !nameError">还没有可改名的电脑。</p>
+      <p v-if="nameStatus" role="status">{{ nameStatus }}</p>
+      <div v-for="host in hosts" :key="host.id" class="computer" :data-host-id="host.id">
+        <div class="computer-heading"><label :for="`computer-name-${host.id}`">{{ host.label }}</label><span class="connection">{{ host.id === 'local' ? '当前服务器' : host.online ? '已连接' : '未连接' }}</span></div>
+        <div v-if="host.id !== 'local'" class="computer-details">
+          <small :title="host.id">编号 {{ host.id.slice(0, 8) }}</small>
+          <small v-if="host.createdAt">配对于 {{ pairedAt(host.createdAt) }}</small>
+        </div>
+        <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>
+        <div v-if="pendingRemoval === host.id" class="remove-confirmation" role="group" :aria-label="`停用编号 ${host.id.slice(0, 8)} 的配对记录`">
+          <p>停用后,机器人将无法连接这条配对记录。需要在这台电脑重新登录或配对才能恢复。</p>
+          <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>
+        </div>
+      </div>
+      <p v-if="!hosts.length && !nameError && !namesLoading">还没有可改名的电脑。</p>
     </div>
   </section>
 </template>
 
 <style scoped>
-.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 label{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.2fr) auto;gap:8px;align-items:center}.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}
+.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}
 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}
+.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)}
 </style>

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
src/client/components/workspace/BotSettingsDialog.vue


+ 94 - 0
tests/client/hostToolsPanel.test.ts

@@ -0,0 +1,94 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { beforeEach, expect, it, vi } from 'vitest'
+import HostToolsPanel from '@/components/app/HostToolsPanel.vue'
+import { apiRequest } from '@/api/client'
+
+vi.mock('@/api/client', () => ({ apiRequest: vi.fn() }))
+const request = vi.mocked(apiRequest)
+const endpoint = '/api/app/admin/desktop-hosts'
+const firstId = '1a111111-1111-4111-8111-111111111111'
+const secondId = '2b222222-2222-4222-8222-222222222222'
+let hosts: { id: string; name: string; displayName: string; enabled: boolean; online: boolean; createdAt: number }[]
+
+beforeEach(() => {
+  request.mockReset()
+  hosts = [
+    { id: firstId, name: '同名电脑', displayName: '', enabled: true, online: false, createdAt: 1_700_000_000_000 },
+    { id: secondId, name: '同名电脑', displayName: '', enabled: true, online: true, createdAt: 1_710_000_000_000 },
+    { id: 'disabled-host', name: '已停用电脑', displayName: '', enabled: false, online: false, createdAt: 1 },
+  ]
+  request.mockImplementation(async (path, options) => {
+    if (path === endpoint) return { hosts: structuredClone(hosts), localName: '' } as never
+    if (path === '/api/app/settings/host-tools') return { approvalPolicy: 'allow', scriptMachine: true, serverComputer: true, vm: true, cloud: true } as never
+    const host = hosts.find(item => path === `${endpoint}/${item.id}/name` || path === `${endpoint}/${item.id}`)
+    if (host && options?.method === 'PUT') host.displayName = (options.body as { name: string }).name
+    else if (host && options?.method === 'DELETE') host.enabled = false
+    else throw new Error(`Unexpected request: ${path}`)
+    return { ok: true } as never
+  })
+})
+
+it('distinguishes same-name registrations by status, ID and pairing time without merging them', async () => {
+  const wrapper = mount(HostToolsPanel)
+  await flushPromises()
+  expect(wrapper.findAll('.computer')).toHaveLength(3)
+  const first = wrapper.get(`[data-host-id="${firstId}"]`)
+  const second = wrapper.get(`[data-host-id="${secondId}"]`)
+  expect(first.text()).toContain('未连接')
+  expect(second.text()).toContain('已连接')
+  expect(first.text()).toContain('编号 1a111111')
+  expect(second.text()).toContain('编号 2b222222')
+  expect(first.text()).toContain('配对于')
+  expect(wrapper.text()).toContain('有同名的配对记录')
+  expect(wrapper.text()).not.toContain('已停用电脑')
+  expect(wrapper.get('[data-host-id="local"]').find('.remove-host').exists()).toBe(false)
+  expect(request.mock.calls.some(([, options]) => options?.method === 'DELETE')).toBe(false)
+  wrapper.unmount()
+})
+
+it('only disables the explicitly confirmed registration, even when names match', async () => {
+  const wrapper = mount(HostToolsPanel)
+  await flushPromises()
+  const first = wrapper.get(`[data-host-id="${firstId}"]`)
+  await first.get('.remove-host').trigger('click')
+  expect(request.mock.calls.some(([, options]) => options?.method === 'DELETE')).toBe(false)
+  await first.get('.remove-confirmation .remove-host').trigger('click')
+  await flushPromises()
+  expect(request).toHaveBeenCalledWith(`${endpoint}/${firstId}`, { method: 'DELETE' })
+  expect(wrapper.find(`[data-host-id="${firstId}"]`).exists()).toBe(false)
+  expect(wrapper.find(`[data-host-id="${secondId}"]`).exists()).toBe(true)
+  expect(wrapper.text()).toContain('这条配对记录已停用')
+  wrapper.unmount()
+})
+
+it('keeps the record and shows an error when disabling fails', async () => {
+  const wrapper = mount(HostToolsPanel)
+  await flushPromises()
+  request.mockRejectedValueOnce(new Error('服务暂时不可用'))
+  const first = wrapper.get(`[data-host-id="${firstId}"]`)
+  await first.get('.remove-host').trigger('click')
+  await first.get('.remove-confirmation .remove-host').trigger('click')
+  await flushPromises()
+  expect(wrapper.find(`[data-host-id="${firstId}"]`).exists()).toBe(true)
+  expect(wrapper.get('[role="alert"]').text()).toContain('服务暂时不可用')
+  wrapper.unmount()
+})
+
+it('renames by registration ID and preserves another computer’s unsaved name', async () => {
+  const wrapper = mount(HostToolsPanel)
+  await flushPromises()
+  const first = wrapper.get(`[data-host-id="${firstId}"]`)
+  const second = wrapper.get(`[data-host-id="${secondId}"]`)
+  await first.get('input').setValue('旧电脑')
+  await second.get('input').setValue('新电脑')
+  await first.get('.name-editor button').trigger('click')
+  await flushPromises()
+  expect(request).toHaveBeenCalledWith(`${endpoint}/${firstId}/name`, { method: 'PUT', body: { name: '旧电脑' } })
+  expect(second.get<HTMLInputElement>('input').element.value).toBe('新电脑')
+  hosts[1]!.online = false
+  await wrapper.get('.names-heading button').trigger('click')
+  await flushPromises()
+  expect(second.text()).toContain('未连接')
+  expect(second.get<HTMLInputElement>('input').element.value).toBe('新电脑')
+  wrapper.unmount()
+})

+ 17 - 1
tests/fixtures/settings-design/main.ts

@@ -25,6 +25,11 @@ const projects=query.has('empty')?[]:[
   {id:'project-b',name:'内容运营',description:'',memberIds:agents.slice(0,3).map(a=>a.id),groupIds:['group-b'],revision:1,archived:false,createdAt:1,updatedAt:1},
 ]
 const identity={serverId:'00000000-0000-4000-8000-000000000099',name:'我的工作空间',displayName:'我的工作空间',revision:1}
+const desktopHosts=[
+  {id:'f184922a-713c-4190-9c46-6e200e6c4c01',name:'LSamienacStudio',displayName:'',enabled:true,online:false,createdAt:Date.parse('2026-09-10T09:30:00+08:00')},
+  {id:'c791ba30-f185-4734-8e72-549bc02e7e02',name:'LSamienacStudio',displayName:'',enabled:true,online:true,createdAt:Date.parse('2026-09-19T18:05:00+08:00')},
+]
+let localComputerName=''
 const realFetch=window.fetch.bind(window)
 window.fetch=async(input,init)=>{
   const url=new URL(typeof input==='string'?input:input instanceof URL?input.href:input.url,location.href)
@@ -49,6 +54,17 @@ window.fetch=async(input,init)=>{
   else if(url.pathname.includes('/memories'))data={memories:[]}
   else if(url.pathname.includes('server-identity'))data=identity
   else if(url.pathname==='/api/app/computers')data={computers:[]}
+  else if(url.pathname==='/api/app/settings/host-tools')data={approvalPolicy:'allow',scriptMachine:true,serverComputer:true,vm:true,cloud:true,...body}
+  else if(url.pathname==='/api/app/admin/desktop-hosts')data={hosts:desktopHosts,localName:localComputerName}
+  else if(url.pathname.startsWith('/api/app/admin/desktop-hosts/')){
+    const id=url.pathname.split('/')[5]
+    const host=desktopHosts.find(item=>item.id===id)
+    if(init?.method==='PUT'&&url.pathname.endsWith('/name')){
+      if(id==='local')localComputerName=body.name
+      else if(host)host.displayName=body.name
+    }else if(init?.method==='DELETE'&&host)host.enabled=false
+    data={ok:true}
+  }
   else if(url.pathname==='/api/app/admin/local-vm')data={configured:true,enabled:true,mode:'shared',maxInstances:2,idleStopMinutes:0,runtime:'docker',daemonUp:true,image:true,images:[{key:'standard',ready:true},{key:'cursor',ready:true}],instances:[]}
   else return new Response(JSON.stringify({error:{message:'此预览没有连接服务'}}),{status:404,headers:{'content-type':'application/json'}})
   return new Response(JSON.stringify(data),{headers:{'content-type':'application/json'}})
@@ -65,7 +81,7 @@ const App=defineComponent({setup(){
     h('div',{style:'display:flex;gap:12px;align-items:center;font:13px var(--font-ui)'},[
       h('span','界面验证 · 示例数据'),h('button',{onClick:()=>opened.value='bot'},'Bot 设置'),h('button',{onClick:()=>opened.value='personal'},'我的设置'),h('button',{onClick:()=>opened.value='editor'},'编辑资料'),
     ]),
-    opened.value==='bot'?h(BotSettingsDialog,{isAdmin:!query.has('member'),onClose:()=>opened.value=null}):null,
+    opened.value==='bot'?h(BotSettingsDialog,{isAdmin:!query.has('member'),initialPage:query.get('view')==='computers'?'computers':'projects',onClose:()=>opened.value=null}):null,
     opened.value==='editor'?h(BotProfileDialog,{agent:agents[0],draft:{...agents[0],source:'["local","default"]'},sources:[{nodeId:'local',profile:'default',name:'丫头'}],profiles:[{name:'default',agentName:'丫头',isDefault:true}],agents,conversations,conversationId:'fixture-chat',isAdmin:!query.has('member'),knowledgeEnabled:true,onClose:()=>opened.value=null,onSave:()=>opened.value=null} as never):null,
     h(SettingsCenterDialog,{open:opened.value==='personal',initialPage:query.get('view')==='desktop-mode'?'desktop-mode':query.get('view')==='profile'?'account-profile':'appearance',isAdmin:!query.has('member'),botMode:true,userName:'samien',theme:theme.value==='dark'?'dark':'light',themePreference:theme.value,profiles:[{name:'default',agentName:'默认机器人',isDefault:true}],activeProfile:{name:'default',agentName:'默认机器人',isDefault:true},onClose:()=>opened.value=null,'onSet-theme':setTheme}),
   ])

Някои файлове не бяха показани, защото твърде много файлове са промени