Ver código fonte

修复电脑选择与桌面窗口目标不一致

Samien 1 mês atrás
pai
commit
5b4ae36

+ 135 - 0
desktop/computer-targets.test.mjs

@@ -0,0 +1,135 @@
+import {test} from 'node:test'
+import assert from 'node:assert/strict'
+import {mkdtemp,writeFile,mkdir,rm} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {pathToFileURL} from 'node:url'
+import {spawn} from 'node:child_process'
+import {_electron as electron,expect} from '@playwright/test'
+
+const root=resolve(import.meta.dirname,'..')
+test('selected previews reach the matching native desktop and switch only after returning the old lease',{timeout:120000},async()=>{
+ const home=await mkdtemp(join(tmpdir(),'yaoyao-computer-targets-')),origin='http://127.0.0.1:18865'
+ const server=spawn(process.execPath,['--import','tsx','tests/fixtures/workspace-server.ts'],{cwd:root,
+  env:{...process.env,WORKSPACE_FIXTURE_HOME:home,WORKSPACE_FIXTURE_TEAM_TOOLS:'1',WORKSPACE_FIXTURE_PORT:'18865',WORKSPACE_FIXTURE_UPSTREAM_PORT:'19165'},stdio:'ignore'})
+ let app
+ try{
+  await expect.poll(async()=>{try{return(await fetch(origin+'/healthz')).status}catch{return 0}},{timeout:30000}).toBe(200)
+  const harness=join(home,'main.mjs')
+  await writeFile(harness,`import {app,BrowserWindow} from 'electron';
+import {installComputerViewer} from '${pathToFileURL(join(root,'desktop/computer-viewer.mjs')).href}';
+app.setPath('userData',${JSON.stringify(join(home,'chromium'))});
+app.whenReady().then(async()=>{let quitting=false;app.on('before-quit',()=>quitting=true);
+const owner=new BrowserWindow({width:1440,height:900,webPreferences:{preload:${JSON.stringify(join(root,'desktop/preload.cjs'))},sandbox:true,contextIsolation:true}});
+installComputerViewer({owner:()=>owner,origin:()=>${JSON.stringify(origin)},preload:${JSON.stringify(join(root,'desktop/preload.cjs'))},quitting:()=>quitting});
+await owner.loadURL(${JSON.stringify(origin+'/conversations')});});`)
+  app=await electron.launch({args:[harness],cwd:root})
+  const page=await app.firstWindow()
+  await page.getByRole('textbox',{name:'账号',exact:true}).fill('fixture')
+  await page.getByRole('textbox',{name:'密码',exact:true}).fill('fixture-pass')
+  await page.getByRole('button',{name:'登录',exact:true}).click()
+  await expect(page.locator('.desktop-sidebar .sidebar-create-trigger')).toBeVisible()
+  const seed=await(await page.request.post(origin+'/__test/computer',{data:{}})).json()
+  const targets=[
+   {key:'desktop:local',backend:'desktop',host:'local',name:'服务器',color:'#263f59'},
+   {key:'desktop:client-mac',backend:'desktop',host:'client-mac',name:'Mac Studio 客户端',color:'#245b49'},
+   {key:'cloud',backend:'cloud',name:'Grok Bot 云端',color:'#6a405e'},
+   {key:'vm',backend:'vm',name:'独立虚拟机',color:'#65502c'},
+  ]
+  const images=await page.evaluate(targets=>Object.fromEntries(targets.map(target=>{
+   const canvas=document.createElement('canvas');canvas.width=1280;canvas.height=800
+   const ctx=canvas.getContext('2d');ctx.fillStyle=target.color;ctx.fillRect(0,0,1280,800)
+   ctx.fillStyle='#ffffff';ctx.font='52px sans-serif';ctx.fillText(target.name,70,180)
+   ctx.font='28px sans-serif';ctx.fillText('验证桌面 · '+target.key,70,245)
+   return [target.key,canvas.toDataURL('image/png').split(',')[1]]
+  })),targets)
+  const permission={supported:true,authorized:true,screen:true,accessibility:true,ready:true,fullAuthorized:true}
+  const hosts=targets.filter(t=>t.backend==='desktop').map(t=>({id:t.host,name:t.name,online:true,local:permission,browser:{available:false}}))
+  const calls=[],leases=new Set()
+  let refuseGiveback=false
+  await app.context().route('**/api/app/settings/host-tools',route=>route.fulfill({json:{scriptMachine:true,serverComputer:true,vm:true,cloud:true}}))
+  await app.context().route('**/api/app/agents/*/**',async route=>{
+   const url=new URL(route.request().url()),path=url.pathname
+   if(!path.startsWith(`/api/app/agents/${seed.agentId}/`))return route.continue()
+   if(path.endsWith('/desktop-environment'))return route.fulfill({json:{hosts,online:true,local:permission}})
+   if(path.endsWith('/cloud-computer')||path.endsWith('/cloud-computer/open'))return route.fulfill({json:{configured:true,running:true,connected:true}})
+   if(path.endsWith('/local-vm'))return route.fulfill({json:{enabled:true,image:true,container:'running',ready:true,mode:'per-bot',fixedCapacity:true}})
+   if(!path.includes('/computer'))return route.continue()
+   const backend=url.searchParams.get('backend'),host=url.searchParams.get('host')
+   const key=backend==='desktop'?'desktop:'+host:backend
+   const target=targets.find(t=>t.key===key)
+   if(!target)return route.fulfill({status:409,json:{error:'Fixture requires an explicit computer target'}})
+   if(path.endsWith('/frame'))return route.fulfill({json:{id:key,data:images[key],width:1280,height:800,generation:1}})
+   if(route.request().method()==='POST'){
+    const op=path.split('/').at(-1);calls.push({op,key})
+    if(op==='giveback'&&refuseGiveback)return route.fulfill({status:409,json:{error:'Fixture giveback temporarily unavailable'}})
+    if(op==='take')leases.add(key)
+    if(op==='giveback')leases.delete(key)
+   }
+   return route.fulfill({json:{mode:leases.has(key)?'human':'idle',backend:backend==='desktop'?'local':backend==='cloud'?'grok':'docker',
+    hostName:target.name,generation:1,controlId:leases.has(key)?key:undefined,token:'fixture-token'}})
+  })
+  await page.goto(origin+'/conversations/'+seed.conversationId)
+  await page.getByRole('button',{name:'电脑与定时任务',exact:true}).click()
+  const dock=page.getByRole('complementary',{name:'机器人电脑面板'})
+  await expect(dock.getByRole('combobox',{name:'显示的桌面'})).toBeVisible()
+  let viewer,previousKey
+  for(const target of targets){
+   await dock.getByRole('combobox',{name:'显示的桌面'}).selectOption(target.key)
+   await expect(dock.locator('.preview img')).toHaveAttribute('src','data:image/png;base64,'+images[target.key])
+   const opened=app.waitForEvent('window')
+   await dock.locator('.preview').click()
+   const oldViewer=viewer;viewer=await opened
+   await expect(viewer.locator('.computer-panel.standalone')).toContainText('人工控制中')
+   await expect(viewer.locator('.computer-screen img')).toHaveAttribute('src','data:image/png;base64,'+images[target.key])
+   const url=new URL(viewer.url())
+   assert.equal(url.searchParams.get('backend'),target.backend)
+   assert.equal(url.searchParams.get('host'),target.host??null)
+   if(oldViewer){
+    assert.equal(oldViewer.isClosed(),true)
+    assert.ok(calls.findIndex(c=>c.op==='giveback'&&c.key===previousKey)<calls.findIndex(c=>c.op==='take'&&c.key===target.key))
+   }
+   assert.deepEqual([...leases],[target.key])
+   previousKey=target.key
+   if(target.host==='client-mac'){
+    const output=join(root,'docs/verification/2026-09-19-computer-targets');await mkdir(output,{recursive:true})
+    await viewer.screenshot({path:join(output,'client-desktop.png')})
+    await page.setViewportSize({width:390,height:844});await page.screenshot({path:join(output,'selected-client-mobile.png')})
+    await page.setViewportSize({width:1440,height:900})
+   }
+  }
+  // Reopening an unchanged target focuses its existing window.
+  await page.evaluate(({id,target})=>window.yaoyaoDesktop.openComputer(id,target),{id:seed.agentId,target:{backend:'vm'}})
+  assert.equal(viewer.isClosed(),false);assert.equal(app.windows().length,2)
+  const rejected=await page.evaluate(async id=>{try{await window.yaoyaoDesktop.openComputer(id,{backend:'vm',host:'client-mac'});return false}catch{return true}},seed.agentId)
+  assert.equal(rejected,true);assert.equal(viewer.isClosed(),false)
+  refuseGiveback=true
+  const beforeFailedSwitch=calls.length
+  const failedSwitch=await page.evaluate(async id=>{
+   try{await window.yaoyaoDesktop.openComputer(id,{backend:'desktop',host:'local'});return ''}
+   catch(error){return error.message}
+  },seed.agentId)
+  assert.match(failedSwitch,/尚未交还/)
+  assert.equal(viewer.isClosed(),false);assert.deepEqual([...leases],['vm'])
+  assert.equal(calls.slice(beforeFailedSwitch).some(c=>c.op==='take'),false)
+  refuseGiveback=false
+  // A manual change inside the viewer must update the identity used for reuse.
+  await viewer.getByRole('button',{name:'键盘与操作',exact:true}).click()
+  await viewer.getByRole('button',{name:'结束控制',exact:true}).click()
+  await viewer.getByRole('combobox',{name:'切换电脑环境'}).selectOption('desktop:local')
+  await expect(viewer.locator('.computer-screen img')).toHaveAttribute('src','data:image/png;base64,'+images['desktop:local'])
+  const reopened=app.waitForEvent('window'),oldViewer=viewer
+  await page.evaluate(id=>window.yaoyaoDesktop.openComputer(id,{backend:'vm'}),seed.agentId)
+  viewer=await reopened
+  await expect(viewer.locator('.computer-panel')).toContainText('人工控制中')
+  assert.equal(oldViewer.isClosed(),true)
+  await expect(viewer.locator('.computer-screen img')).toHaveAttribute('src','data:image/png;base64,'+images.vm)
+  await viewer.getByRole('button',{name:'交还并关闭',exact:true}).click()
+  await expect.poll(()=>viewer.isClosed()).toBe(true)
+  assert.equal(leases.size,0)
+ }finally{
+  await app?.close().catch(()=>{})
+  server.kill('SIGTERM');if(server.exitCode===null)await new Promise(done=>server.once('exit',done))
+  await rm(home,{recursive:true,force:true})
+ }
+})

+ 44 - 10
desktop/computer-viewer.mjs

@@ -2,18 +2,47 @@ import { BrowserWindow, ipcMain } from 'electron'
 
 /** Local authenticated viewer. Agent ownership and control leases remain server-authoritative. */
 export function installComputerViewer({ owner, origin, preload, quitting = () => false }) {
-  let viewer, agentId, approvedClose = false
+  let viewer, viewerURL, currentTargetURL, approvedClose = false, opening = Promise.resolve()
   const trusted = event => event.sender === owner()?.webContents && event.senderFrame === event.sender.mainFrame
     && new URL(event.senderFrame.url).origin === origin()
-  ipcMain.handle('desktop-computer:open', async (event, id) => {
-    if (!trusted(event) || typeof id !== 'string' || !/^[\w-]{1,128}$/.test(id)) throw new Error('电脑窗口请求无效')
+  const targetURL = (id, target = {}) => {
+    if (typeof id !== 'string' || !/^[\w-]{1,128}$/.test(id)) throw new Error('电脑窗口请求无效')
+    if (!target || typeof target !== 'object' || Array.isArray(target)
+      || Object.keys(target).some(key => !['backend', 'host'].includes(key))
+      || (target.backend !== undefined && !['desktop', 'cloud', 'vm'].includes(target.backend))
+      || (target.host !== undefined && (target.backend !== 'desktop' || typeof target.host !== 'string' || !/^[\w-]{1,128}$/.test(target.host))))
+      throw new Error('所选电脑无效,请重新选择')
+    const url = new URL(`/conversations/computer/${encodeURIComponent(id)}`, origin())
+    if (target.backend) url.searchParams.set('backend', target.backend)
+    if (target.backend === 'desktop') url.searchParams.set('host', target.host || 'local')
+    return url.href
+  }
+  ipcMain.handle('desktop-computer:open', (event, id, target) => {
+    if (!trusted(event)) throw new Error('电脑窗口请求无效')
+    const requestedURL = targetURL(id, target)
+    const pending = opening.catch(() => {}).then(() => open(requestedURL))
+    opening = pending
+    return pending
+  })
+  async function open(url) {
+    if (quitting()) return false
     if (viewer && !viewer.isDestroyed()) {
-      viewer.show(); viewer.focus()
-      if (agentId !== id) throw new Error('请先交还并关闭当前电脑,再接管另一台电脑')
-      return true
+      if (currentTargetURL === url) { viewer.show(); viewer.focus(); return true }
+      // The old page must return its lease before another target can open.
+      const previous = viewer
+      await new Promise((resolve, reject) => {
+        const closed = () => { clearTimeout(timeout); resolve() }
+        const timeout = setTimeout(() => {
+          previous.removeListener('closed', closed)
+          if (!previous.isDestroyed()) { previous.show(); previous.focus() }
+          reject(new Error('当前电脑尚未交还,请在电脑窗口交还并关闭后重试'))
+        }, 15000)
+        previous.once('closed', closed)
+        previous.close()
+      })
     }
-    const url = new URL(`/conversations/computer/${encodeURIComponent(id)}`, origin()).href
-    agentId = id; approvedClose = false
+    if (quitting()) return false
+    viewerURL = url; currentTargetURL = url; approvedClose = false
     const current = viewer = new BrowserWindow({ parent: owner(), modal: false, width: 1220, height: 820,
       minWidth: 760, minHeight: 520, show: false, title: '电脑接管 · 夭夭 AI', backgroundColor: '#111315', autoHideMenuBar: true,
       webPreferences: { preload, sandbox: true, contextIsolation: true, nodeIntegration: false, webSecurity: true } })
@@ -26,14 +55,19 @@ export function installComputerViewer({ owner, origin, preload, quitting = () =>
         event.preventDefault(); current.webContents.send('desktop-computer:request-close')
       }
     })
-    current.on('closed', () => { if (viewer === current) { viewer = undefined; agentId = undefined } })
+    current.on('closed', () => { if (viewer === current) { viewer = undefined; viewerURL = undefined; currentTargetURL = undefined } })
     current.once('ready-to-show', () => { current.show(); current.focus() })
     try { await current.loadURL(url) } catch (error) { approvedClose = true; current.close(); throw error }
     return true
+  }
+  ipcMain.handle('desktop-computer:target', (event, target) => {
+    if (event.sender !== viewer?.webContents || event.senderFrame !== event.sender.mainFrame
+      || event.senderFrame.url !== viewerURL) throw new Error('电脑窗口身份无效')
+    currentTargetURL = targetURL(decodeURIComponent(new URL(viewerURL).pathname.split('/').at(-1)), target)
   })
   ipcMain.handle('desktop-computer:closed', event => {
     if (event.sender !== viewer?.webContents || event.senderFrame !== event.sender.mainFrame
-      || new URL(event.senderFrame.url).pathname !== `/conversations/computer/${agentId}`) throw new Error('电脑窗口身份无效')
+      || event.senderFrame.url !== viewerURL) throw new Error('电脑窗口身份无效')
     approvedClose = true; viewer.close()
   })
   const close = () => { approvedClose = true; viewer?.destroy() }

+ 2 - 1
desktop/preload.cjs

@@ -10,7 +10,8 @@ contextBridge.exposeInMainWorld('yaoyaoDesktop', Object.freeze({
   openRemoteLogin: () => ipcRenderer.invoke('desktop:remote-login'),
   /** 'local' on the YaoYao server Mac; paired host uuid in remote mode; null if unknown. */
   deviceHost: () => ipcRenderer.invoke('desktop:device-host'),
-  openComputer: id => ipcRenderer.invoke('desktop-computer:open', id),
+  openComputer: (id, target) => ipcRenderer.invoke('desktop-computer:open', id, target),
+  computerTargetChanged: target => ipcRenderer.invoke('desktop-computer:target', target),
   computerClosed: () => ipcRenderer.invoke('desktop-computer:closed'),
   onComputerClose: callback => {
     const listener = () => callback()

+ 21 - 0
docs/verification/2026-09-19-computer-targets/README.md

@@ -0,0 +1,21 @@
+# 电脑选择与桌面窗口验证
+
+修复后,侧栏预览、打开请求、独立窗口和实际控制请求均保留同一个 backend/host。切换预览时立即清除旧截图,并忽略旧目标迟到的响应。显式选中的主机离线或消失时显示不可用,不自动接管服务器。
+
+Electron 的窗口复用按实际电脑目标判断;更换目标先由旧窗口交还控制权,完成关闭后才接管新电脑。交还失败会保留原窗口,不接管新目标。窗口内部手动切换环境后,也同步更新用于判断窗口复用的目标。
+
+验证命令:
+
+```sh
+NODE_OPTIONS=--no-experimental-webstorage npx vitest run tests/client/computerTargetSelection.test.ts tests/client/localVmImages.test.ts
+npm run build
+node --test desktop/computer-targets.test.mjs
+```
+
+15 项前端测试通过。真实 Electron 测试使用临时账号、模拟电脑状态和四种不同画面,覆盖服务器、客户端、Grok Bot 云端和独立虚拟机,以及窗口复用、内部切换、无效目标和交还失败。没有操作生产电脑。
+
+截图:[客户端桌面窗口](client-desktop.png)、[390px 手机宽度的客户端预览](selected-client-mobile.png)。截图中的绿色桌面是明确标注的验证画面。
+
+扩展执行 `tests/client/workspaceShell.test.ts` 时,菜单测试仍要求已移除的「添加远程机器人」选项;在修改前的 `a6a8eae` 源码副本上复现了相同失败,与本次桌面目标修复无关。
+
+本次为代码与本地运行验证,尚未部署至 `10.10.1.200`。原生窗口参数传递涉及 Electron preload 和主进程,需要更新桌面客户端并重新启动;仅刷新网页不能更新原生窗口代码。

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


BIN
docs/verification/2026-09-19-computer-targets/selected-client-mobile.png


+ 14 - 11
src/client/components/workspace/ComputerPanel.vue

@@ -7,9 +7,9 @@ import {createUuid} from '@/utils/id'
 import type {WorkspaceAgent} from '@shared/workspace'
 import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
 import type {JsonValue} from '@shared/types'
-const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;standalone?:boolean;autoTake?:boolean;backend?:'desktop'|'cloud'|'vm',host?:string}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[]}>()
+const props=defineProps<{agents:WorkspaceAgent[];embedded?:boolean;standalone?:boolean;autoTake?:boolean;backend?:'desktop'|'cloud'|'vm',host?:string}>(),emit=defineEmits<{close:[];changed:[];controlRequest:[];targetChanged:[backend:'desktop'|'cloud'|'vm',host?:string]}>()
 const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
-const backend=ref(props.backend),host=ref(props.host??''),envTargets=ref<{hosts:DesktopEnvironmentState['hosts'];cloudConfigured:boolean;vmEnabled:boolean}>()
+const backend=ref(props.backend),host=ref(props.host??(props.backend==='desktop'?'local':'')),envTargets=ref<{hosts:DesktopEnvironmentState['hosts'];cloudConfigured:boolean;vmEnabled:boolean}>()
 const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
 const controlsOpen=ref(false),actualSize=ref(false),browserState=ref<BotBrowserState>()
 const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
@@ -30,6 +30,8 @@ const targets=computed(()=>{
   return list
 })
 const currentKey=computed(()=>targets.value.find(t=>t.backend===backend.value&&(t.host??'')===host.value)?.key??'')
+const targetAvailable=computed(()=>targets.value.some(t=>t.key===currentKey.value&&!t.disabled))
+watch([backend,host],([selectedBackend,selectedHost])=>{if(selectedBackend)emit('targetChanged',selectedBackend,selectedHost||undefined)},{immediate:true})
 async function loadTargets(){
   if(!selected.value)return
   const selectedId=selected.value,id=encodeURIComponent(selectedId)
@@ -58,7 +60,7 @@ const endpoint=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/comput
 const query=()=>{const q=new URLSearchParams();if(backend.value)q.set('backend',backend.value);if(backend.value==='desktop'&&host.value)q.set('host',host.value);const qs=q.toString();return qs?'?'+qs:''}
 async function post(path:string,body:unknown){return apiRequest<any>(endpoint()+path+query(),{method:'POST',body:body as JsonValue})}
 async function refresh(){
-  if(polling||busy.value||closed||!selected.value||document.hidden)return
+  if(polling||busy.value||closed||!selected.value||document.hidden||!targetAvailable.value)return
   polling=true;const current=++generation,base=endpoint()
   try{
     const status=await apiRequest<ComputerControlStatus>(base+query())
@@ -81,7 +83,7 @@ async function run(action:()=>Promise<void>){
   if(busy.value)return;generation++;busy.value=true;error.value=''
   try{await action()}catch(cause){error.value=cause instanceof Error?cause.message:'电脑操作失败'}finally{busy.value=false;await refresh();void flushInputs()}
 }
-function take(){return run(async()=>{takeRequestId??=createUuid();const value=await post('/take',{requestId:takeRequestId});ticket.value={controlId:value.controlId,token:value.token};state.value=value;lastRenew=0})}
+function take(){return run(async()=>{if(!targetAvailable.value)throw new Error('所选电脑不可用,请重新选择');takeRequestId??=createUuid();const value=await post('/take',{requestId:takeRequestId});ticket.value={controlId:value.controlId,token:value.token};state.value=value;lastRenew=0})}
 const inputs:ComputerInput[]=[];let flushing=false,pendingClick:ComputerInput|undefined,clickTimer:ReturnType<typeof setTimeout>|undefined
 function send(action:ComputerInput){
   if(!canSend.value||closed)return
@@ -127,15 +129,16 @@ async function fullscreen(){try{if(document.fullscreenElement)await document.exi
 async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
 watch(selected,()=>{
   inputs.length=0;connectionOK.value=false;generation++;takeRequestId=undefined;frame.value=undefined;ticket.value=undefined;state.value={mode:'off'};error.value=''
-  backend.value=props.backend;host.value='';envTargets.value=undefined
-  void loadTargets().then(()=>{if(!currentKey.value){const first=targets.value.find(t=>!t.disabled);if(first)switchTarget(first.key)}});void refresh()
+  backend.value=props.backend;host.value=props.host??(props.backend==='desktop'?'local':'');envTargets.value=undefined
+  void loadTargets().then(async()=>{if(!backend.value){const first=targets.value.find(t=>!t.disabled);if(first)await switchTarget(first.key)}await refresh()}).catch(cause=>{error.value=cause instanceof Error?cause.message:'无法读取电脑列表'})
 })
 onMounted(async()=>{
  if(!props.embedded&&!props.standalone)dialog.value?.showModal()
  try{
   await loadTargets()
   if(closed)return
-  if(!currentKey.value){const first=targets.value.find(t=>!t.disabled);if(first)await switchTarget(first.key)}
+  if(!backend.value){const first=targets.value.find(t=>!t.disabled);if(first)await switchTarget(first.key)}
+  if(!targetAvailable.value)throw new Error('所选电脑不可用,请重新选择;未打开其他电脑')
   if(props.autoTake&&!closed)await take()
  }catch(cause){error.value=cause instanceof Error?cause.message:'无法读取电脑列表'}
  if(!closed)void cycle()
@@ -145,7 +148,7 @@ defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.
 </script>
 <template>
   <Teleport to="body" :disabled="embedded||standalone">
-    <component :is="embedded||standalone?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded,standalone}" :aria-label="state.backend==='local'?'本机':state.backend==='browser'?'浏览器':'隔离电脑'" @cancel.prevent="close">
+    <component :is="embedded||standalone?'section':'dialog'" ref="dialog" class="computer-panel" :class="{embedded,standalone}" :aria-label="targets.find(t=>t.key===currentKey)?.label||'电脑桌面'" @cancel.prevent="close">
       <header>
         <div><strong>{{state.backend==='grok'?'Grok Bot 云端电脑':state.backend==='local'?(state.hostName??'服务器桌面'):state.backend==='browser'?'浏览器 · '+state.hostName:'本地虚拟机'}}</strong><small>{{ labels[state.mode] }}</small></div>
         <select v-if="targets.length>1" :value="currentKey" aria-label="切换电脑环境" :disabled="!!ticket||busy" @change="switchTarget(($event.target as HTMLSelectElement).value)"><option v-for="target in targets" :key="target.key" :value="target.key" :disabled="target.disabled">{{ target.label }}</option></select>
@@ -163,17 +166,17 @@ defineExpose({take,close,releaseControl:async()=>{await run(giveBack);if(ticket.
       </header>
       <BrowserToolbar v-if="state.backend==='browser'" :state="browserState" :enabled="controllable" @action="browserAction"/>
       <div class="computer-screen" :class="{controlling:canSend,'actual-size':actualSize}">
-        <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="state.backend==='local'?'服务器当前画面':state.backend==='browser'?'浏览器当前画面':'隔离电脑当前画面'" draggable="false" tabindex="0" title="点按后可使用键盘、滚轮和拖动;按 Esc 退出键盘控制" @pointerdown="down" @pointerup="up" @pointercancel="dragStart=undefined" @wheel="wheel" @keydown="keydown" @click="click" @contextmenu.prevent="click">
+        <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="(targets.find(t=>t.key===currentKey)?.label||'电脑')+'当前画面'" draggable="false" tabindex="0" title="点按后可使用键盘、滚轮和拖动;按 Esc 退出键盘控制" @pointerdown="down" @pointerup="up" @pointercancel="dragStart=undefined" @wheel="wheel" @keydown="keydown" @click="click" @contextmenu.prevent="click">
         <p v-else>{{ state.mode==='off' ? '打开电脑后可查看桌面。' : '正在读取电脑画面…' }}</p>
         <span v-if="frame&&(!mine||frame.generation!==state.generation)" class="view-label">{{mine?'正在同步画面':state.mode==='off'?'上次画面':'仅查看'}}</span>
       </div>
       <p v-if="error||state.error" class="computer-error" role="alert">{{ error||state.error }}</p>
       <footer v-if="controlsOpen||(!embedded&&!ticket)">
         <p v-if="state.backend==='grok'">多个机器人共享同一台 Grok Bot 云端电脑、工作文件与浏览器资料。</p>
-        <p v-else-if="state.backend==='local'">服务器就是夭夭所在的这台电脑。先看真实桌面,再点击、输入、按键或滚动。</p>
+        <p v-else-if="state.backend==='local'">当前显示{{state.hostName||'所选电脑'}}的桌面,点击、输入、按键和滚动都会发送到这台电脑。</p>
         <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,可同时执行命令与文件操作,桌面操作交替进行;人工接管时机器人暂停等待。</p>
         <p v-if="!ticket">查看不会输入任何内容。接管后机器人会暂停,已有操作结束后才允许人工操作。</p>
-        <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="embedded?emit('controlRequest'):take()">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
+        <button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy||!targetAvailable" @click="embedded?emit('controlRequest'):take()">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
         <template v-if="mine">
 
           <section v-if="controlsOpen" class="manual-input">

+ 36 - 29
src/client/components/workspace/LocalVmChatPanel.vue

@@ -25,18 +25,18 @@ const activeEnvLine=computed(()=>[
 ].filter(Boolean).join(' · '))
 const previewTargets=computed(()=>{
  const list:{key:string;label:string;backend:'desktop'|'cloud'|'vm';host?:string}[]=[]
- if(computers.value.scriptMachine||computers.value.serverComputer)for(const h of desktopHosts.value){if(h.online)list.push({key:'desktop:'+h.id,label:h.id==='local'?'服务器 · '+(h.name||'服务器'):'脚本机 · '+(h.name||'未命名'),backend:'desktop',host:h.id})}
+ if(computers.value.scriptMachine||computers.value.serverComputer)for(const h of desktopHosts.value){list.push({key:'desktop:'+h.id,label:(h.id==='local'?'服务器 · '+(h.name||'服务器'):'脚本机 · '+(h.name||'未命名'))+(h.online?'':' · 离线'),backend:'desktop',host:h.id})}
  if(computers.value.cloud&&cloud.value?.configured)list.push({key:'cloud',label:'云端 · Grok Bot',backend:'cloud'})
  if(computers.value.vm)list.push({key:'vm',label:'虚拟环境',backend:'vm'})
  return list
 })
 const previewKey=ref('')
-const previewTarget=computed(()=>previewTargets.value.find(t=>t.key===previewKey.value)??previewTargets.value[0])
-watch(previewTargets,list=>{if(previewKey.value&&!list.some(t=>t.key===previewKey.value))previewKey.value=''},{deep:true})
+const previewTarget=computed(()=>previewKey.value?previewTargets.value.find(t=>t.key===previewKey.value):previewTargets.value.find(t=>t.backend!=='desktop'||desktopHosts.value.find(h=>h.id===t.host)?.online))
+const previewLoading=ref(false)
 const previewEmptyText=computed(()=>{
  const target=previewTarget.value
- if(!target)return '暂无已开放的电脑'
- if(target.backend==='desktop'){const host=native.value?.hosts?.find(h=>h.id===target.host);return host?.local.ready?'正在读取电脑画面…':'请完成这台电脑的屏幕控制授权'}
+ if(!target)return previewKey.value?'所选电脑已不可用,请重新选择':'暂无已开放的电脑'
+ if(target.backend==='desktop'){const host=native.value?.hosts?.find(h=>h.id===target.host);return !host?.online?'这台电脑已离线':host.local.ready?'正在读取电脑画面…':'请完成这台电脑的屏幕控制授权'}
  if(target.backend==='cloud')return cloud.value?.configured?(cloud.value?.running?'正在连接桌面…':'打开云端电脑后可查看桌面'):'尚未连接 Grok Bot 账号'
  if(!state.value?.enabled)return '未使用电脑'
  if(!state.value.image)return '本地虚拟机尚未准备'
@@ -50,8 +50,24 @@ function authorizeHost(hostId:string,scope?:'full'){
  if(scope)body.scope=scope
  void run(()=>apiRequest(`/api/app/agents/${selected.value}/desktop-environment/authorize`,{method:'POST',body,timeoutMs:125000}))
 }
-let timer:ReturnType<typeof setTimeout>|undefined,closed=false,revision=0
+let timer:ReturnType<typeof setTimeout>|undefined,closed=false,revision=0,previewRevision=0
 const base=()=>`/api/app/agents/${encodeURIComponent(selected.value)}/local-vm`
+async function refreshFrame(){
+ if(closed||props.active===false||!selected.value||document.hidden||busy.value)return
+ const version=++previewRevision,id=selected.value,target=previewTarget.value
+ const usable=target?.backend==='vm'?state.value?.container==='running'&&state.value?.ready
+  :target?.backend==='cloud'?!!cloud.value?.running&&!!cloud.value?.connected
+  :target?.backend==='desktop'&&desktopHosts.value.some(h=>h.id===target.host&&h.online&&h.local.ready)
+ if(!target||!usable){frame.value=undefined;previewLoading.value=false;return}
+ previewLoading.value=true
+ try{
+  const query=new URLSearchParams({backend:target.backend});if(target.host)query.set('host',target.host)
+  const image=await apiRequest<ComputerFrame>(`/api/app/agents/${encodeURIComponent(id)}/computer/frame?${query}`)
+  if(version===previewRevision&&!closed&&id===selected.value)frame.value=image
+ }catch{if(version===previewRevision&&!closed)frame.value=undefined}
+ finally{if(version===previewRevision&&!closed)previewLoading.value=false}
+}
+watch(()=>previewTarget.value?.key,()=>{previewRevision++;frame.value=undefined;void refreshFrame()},{flush:'sync'})
 async function refresh(){
  if(closed||props.active===false||!selected.value||document.hidden||busy.value)return
  const version=revision
@@ -65,31 +81,21 @@ async function refresh(){
   ])
   if(version!==revision||closed)return
   native.value=desktopResult;cloud.value=cloudResult;state.value=vmResult
-  const target=previewTarget.value
-  if(target){
-   const framePath=`/api/app/agents/${selected.value}/computer/frame?backend=${target.backend}`+(target.host?'&host='+encodeURIComponent(target.host):'')
-   const usable=target.backend==='vm'?state.value?.container==='running'&&state.value?.ready
-    :target.backend==='cloud'?!!cloud.value?.running&&!!cloud.value?.connected
-    :!!native.value?.hosts?.find(h=>h.id===target.host)?.local.ready
-   if(usable){try{const image=await apiRequest<ComputerFrame>(framePath);if(version===revision&&!closed)frame.value=image}catch{if(version===revision&&!closed)frame.value=undefined}}
-   else if(version===revision)frame.value=undefined
-  }
-  if(!target)frame.value=undefined
+  await refreshFrame()
+  if(version!==revision||closed)return
   error.value=''
  }catch(e){if(version===revision)error.value=e instanceof Error?e.message:'无法读取电脑画面'}finally{if(version===revision)loading.value=false}
 }
 async function run(work:()=>Promise<unknown>){let ok=true;if(busy.value)return false;busy.value=true;error.value='';try{await work();emit('changed')}catch(e){ok=false;error.value=e instanceof Error?e.message:'电脑操作未完成'}finally{busy.value=false;await refresh()}return ok}
 async function openDesktop(backend:'desktop'|'cloud'|'vm',host?:string){
- if(!agent.value)return
- if(backend==='cloud'){await run(()=>apiRequest(`/api/app/agents/${selected.value}/cloud-computer/open`,{method:'POST',body:{}}));if(error.value)return}
- emit('desktop',agent.value,backend,host)
+ const selectedAgent=agent.value
+ if(!selectedAgent||busy.value)return
+ if(backend==='cloud'&&!await run(()=>apiRequest(`/api/app/agents/${encodeURIComponent(selectedAgent.id)}/cloud-computer/open`,{method:'POST',body:{}})))return
+ if(!closed&&agent.value?.id===selectedAgent.id)emit('desktop',selectedAgent,backend,host)
 }
 async function openPreview(){
- if(!agent.value)return
  const target=previewTarget.value
- if(!target)return
- if(target.backend==='cloud'){await run(()=>apiRequest(`/api/app/agents/${selected.value}/cloud-computer/open`,{method:'POST',body:{}}));if(error.value)return}
- emit('desktop',agent.value,target.backend,target.host)
+ if(target&&frame.value)await openDesktop(target.backend,target.host)
 }
 function action(value:LocalVmAction){
  if(value==='recreate'&&!confirm('重建这台虚拟机?当前桌面程序将关闭,工作文件和浏览器资料会保留。'))return
@@ -98,9 +104,9 @@ function action(value:LocalVmAction){
 }
 async function cycle(){const version=revision;await refresh();if(!closed&&version===revision)timer=setTimeout(cycle,3000)}
 watch(()=>props.agents.map(a=>a.id).join(','),()=>{if(!props.agents.some(a=>a.id===selected.value))selected.value=props.agents[0]?.id??''},{immediate:true})
-watch(selected,()=>{revision++;state.value=undefined;native.value=undefined;cloud.value=undefined;frame.value=undefined;error.value='';loading.value=true;previewKey.value='';clearTimeout(timer);void cycle()},{immediate:true})
+watch(selected,()=>{revision++;previewRevision++;state.value=undefined;native.value=undefined;cloud.value=undefined;frame.value=undefined;error.value='';loading.value=true;previewKey.value='';clearTimeout(timer);void cycle()},{immediate:true})
 watch(()=>props.active,value=>{if(value)void refresh()})
-onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
+onBeforeUnmount(()=>{closed=true;revision++;previewRevision++;clearTimeout(timer)})
 </script>
 <template>
  <aside class="local-vm-chat-panel" :class="{embedded}" aria-label="机器人电脑面板">
@@ -109,10 +115,10 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
    <label v-if="agents.length>1" class="agent-select">机器人<select v-model="selected" :disabled="busy"><option v-for="item in agents" :key="item.id" :value="item.id">{{item.name}}</option></select></label>
    <p v-if="!agent">当前聊天没有可用的机器人。</p>
    <template v-else>
-    <div class="screen-caption"><span>{{agent.name}}的电脑</span><select v-if="previewTargets.length>1" v-model="previewKey" class="screen-select" aria-label="显示的桌面"><option value="">自动</option><option v-for="target in previewTargets" :key="target.key" :value="target.key">{{target.label}}</option></select><small v-else-if="activeEnvLine">{{activeEnvLine}}</small></div>
-    <button class="preview" :disabled="!frame||busy" :aria-label="`打开${agent.name}的桌面`" @click="openPreview()">
-     <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="`${agent.name}的电脑画面`">
-     <span v-else class="empty"><AppIcon name="monitor" :size="26"/><span>{{loading?'正在检查电脑…':previewEmptyText}}</span></span>
+    <div class="screen-caption"><span>{{agent.name}}的电脑</span><select v-if="previewTargets.length>1||previewKey" v-model="previewKey" :disabled="busy" class="screen-select" aria-label="显示的桌面"><option value="">自动{{previewTarget&&!previewKey?' · '+previewTarget.label:''}}</option><option v-if="previewKey&&!previewTarget" :value="previewKey" disabled>所选电脑不可用</option><option v-for="target in previewTargets" :key="target.key" :value="target.key">{{target.label}}</option></select><small v-else-if="activeEnvLine">{{previewTarget?.label||activeEnvLine}}</small></div>
+    <button class="preview" :disabled="!frame||busy" :aria-label="`打开${previewTarget?.label||agent.name}的桌面`" @click="openPreview()">
+     <img v-if="frame" :src="`data:image/png;base64,${frame.data}`" :alt="`${previewTarget?.label||agent.name}的电脑画面`">
+     <span v-else class="empty"><AppIcon name="monitor" :size="26"/><span>{{loading?'正在检查电脑…':previewLoading?'正在读取所选电脑画面…':previewEmptyText}}</span></span>
      <span v-if="frame" class="open-badge"><AppIcon name="external" :size="12"/>打开桌面</span>
     </button>
     <p v-if="error||state?.problem" class="problem" role="alert">{{error||state?.problem}}</p>
@@ -181,6 +187,7 @@ onBeforeUnmount(()=>{closed=true;revision++;clearTimeout(timer)})
 .computer-tabs button:focus-visible{outline-offset:-2px}
 .computer-options{display:flex;flex-direction:column;gap:14px}
 .computer-options:empty{display:none}
+.screen-caption{gap:8px}.screen-select{min-height:44px;min-width:0}
 </style>
 
 <style scoped>.cloud-settings{display:grid;gap:10px;padding:14px;border:1px solid var(--line);border-radius:10px}.cloud-settings input{min-height:44px;font:inherit;color:var(--text-primary);background:var(--surface-raised);border:1px solid var(--line);border-radius:8px;padding:8px;min-width:0}.computer-tabs{display:grid;grid-template-columns:1fr 1fr;overflow:visible}.computer-tabs button{text-align:left;justify-content:flex-start;white-space:normal;min-height:48px}</style>

+ 2 - 1
src/client/env.d.ts

@@ -16,7 +16,8 @@ interface Window {
     switchMode?(mode: 'client' | 'server'): Promise<{ ok: boolean; error?: string; pendingLogin?: boolean }>
     openRemoteLogin?(): Promise<void>
     deviceHost?(): Promise<{ deviceHost: string | null }>
-    openComputer(id: string): Promise<boolean>
+    openComputer(id: string, target?: { backend?: 'desktop' | 'cloud' | 'vm'; host?: string }): Promise<boolean>
+    computerTargetChanged?(target: { backend: 'desktop' | 'cloud' | 'vm'; host?: string }): Promise<void>
     computerClosed(): Promise<void>
     onComputerClose(callback: () => void): () => void
   }

+ 12 - 1
src/client/views/ComputerViewerView.vue

@@ -5,8 +5,12 @@ import { apiRequest } from '@/api/client'
 import ComputerPanel from '@/components/workspace/ComputerPanel.vue'
 import type { WorkspaceAgent } from '@shared/workspace'
 const route = useRoute(), agent = ref<WorkspaceAgent>(), error = ref(''), panel = ref<InstanceType<typeof ComputerPanel>>()
+const backend = ref<'desktop' | 'cloud' | 'vm'>(), host = ref<string>()
 let detach: (() => void) | undefined
 async function finish(){await window.yaoyaoDesktop?.computerClosed()}
+async function targetChanged(backend: 'desktop' | 'cloud' | 'vm', host?: string) {
+  await window.yaoyaoDesktop?.computerTargetChanged?.({backend,host})
+}
 async function close() {
   if (panel.value) await panel.value.close()
   else await window.yaoyaoDesktop?.computerClosed()
@@ -14,6 +18,13 @@ async function close() {
 onMounted(async () => {
   detach = window.yaoyaoDesktop?.onComputerClose(() => { void close() })
   try {
+    const requestedBackend = route.query.backend, requestedHost = route.query.host
+    if (requestedBackend !== undefined && requestedBackend !== 'desktop' && requestedBackend !== 'cloud' && requestedBackend !== 'vm')
+      throw new Error('所选电脑无效,请关闭窗口后重新选择')
+    if (requestedHost !== undefined && (requestedBackend !== 'desktop' || typeof requestedHost !== 'string' || !/^[\w-]{1,128}$/.test(requestedHost)))
+      throw new Error('所选电脑主机无效,请关闭窗口后重新选择')
+    backend.value = requestedBackend
+    host.value = typeof requestedHost === 'string' ? requestedHost : undefined
     const result = await apiRequest<{agents: WorkspaceAgent[]}>('/api/app/agents')
     agent.value = result.agents.find(item => item.id === route.params.agentId)
     if (!agent.value) throw new Error('这台电脑不可用,或你已没有访问权限')
@@ -22,7 +33,7 @@ onMounted(async () => {
 onBeforeUnmount(() => detach?.())
 </script>
 <template>
-  <ComputerPanel v-if="agent" ref="panel" :agents="[agent]" standalone auto-take @close="finish" />
+  <ComputerPanel v-if="agent" ref="panel" :agents="[agent]" :backend="backend" :host="host" standalone auto-take @close="finish" @target-changed="targetChanged" />
   <main v-else class="viewer-loading" role="status"><p>{{ error || '正在连接电脑…' }}</p><button v-if="error" @click="close">关闭窗口</button></main>
 </template>
 <style scoped>

+ 1 - 1
src/client/views/ConversationsView.vue

@@ -270,7 +270,7 @@ const creatingTask=ref(false)
 const CREATE_TASK_VALUE='__create_task__'
 function toggleDock(view:'computer'|'inspector'){dockView.value=dockView.value===view?undefined:view}
 async function openComputer(agent:Agent,backend?:'desktop'|'cloud'|'vm',host?:string){
-  if(window.yaoyaoDesktop?.openComputer){try{await window.yaoyaoDesktop.openComputer(agent.id)}catch(cause){error.value=cause instanceof Error?cause.message:'无法打开电脑窗口'}return}
+  if(window.yaoyaoDesktop?.openComputer){try{await window.yaoyaoDesktop.openComputer(agent.id,{backend,host})}catch(cause){error.value=cause instanceof Error?cause.message:'无法打开电脑窗口'}return}
   desktopAgent.value=agent;desktopBackend.value=backend;desktopHost.value=host??'';computerOpen.value=true
 }
 async function leaveComputer(){

+ 115 - 0
tests/client/computerTargetSelection.test.ts

@@ -0,0 +1,115 @@
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import {flushPromises,mount} from '@vue/test-utils'
+import Preview from '../../src/client/components/workspace/LocalVmChatPanel.vue'
+import ComputerPanel from '../../src/client/components/workspace/ComputerPanel.vue'
+import Viewer from '../../src/client/views/ComputerViewerView.vue'
+import {apiRequest} from '../../src/client/api/client'
+
+vi.mock('../../src/client/api/client',()=>({apiRequest:vi.fn()}))
+const route={params:{agentId:'bot'},query:{} as Record<string,unknown>}
+vi.mock('vue-router',()=>({useRoute:()=>route}))
+const agent={id:'bot',name:'竹儿',profile:'default',nodeId:'local'} as any
+const permission={supported:true,authorized:true,screen:true,accessibility:true,ready:true,fullAuthorized:true}
+let hosts:any[],readFrame:(key:string)=>unknown
+const screenshot=(key:string)=>({id:key,data:key,width:100,height:80,generation:1})
+const stubs={AppIcon:true,GrokAuthPanel:true,BrowserToolbar:true}
+beforeEach(()=>{
+ vi.spyOn(document,'hidden','get').mockReturnValue(false)
+ route.query={}
+ hosts=[{id:'local',name:'服务器',online:true,local:permission},{id:'client-mac',name:'Mac Studio',online:true,local:permission}]
+ readFrame=screenshot
+ vi.mocked(apiRequest).mockImplementation(async(path,options:any)=>{
+  const url=new URL(path,'http://fixture'),key=url.searchParams.get('host')||url.searchParams.get('backend')||'unspecified'
+  if(url.pathname==='/api/app/agents')return {agents:[agent]} as any
+  if(url.pathname.endsWith('/host-tools'))return {serverComputer:true,scriptMachine:true,cloud:true,vm:true} as any
+  if(url.pathname.endsWith('/desktop-environment'))return {hosts:hosts.map(h=>({...h}))} as any
+  if(url.pathname.endsWith('/cloud-computer'))return {configured:true,running:true,connected:true} as any
+  if(url.pathname.endsWith('/cloud-computer/open'))return {} as any
+  if(url.pathname.endsWith('/local-vm'))return {enabled:true,image:true,container:'running',ready:true} as any
+  if(url.pathname.endsWith('/frame'))return await readFrame(key) as any
+  return {mode:options?.method==='POST'?'human':'idle',backend:key==='cloud'?'grok':key==='vm'?'docker':'local',generation:1,hostName:key,controlId:'control',token:'fixture-token'} as any
+ })
+})
+afterEach(()=>{vi.restoreAllMocks();vi.clearAllMocks()})
+
+it('refreshes the selected preview immediately and never replaces it with a late frame from another computer',async()=>{
+ const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
+ try{
+  await flushPromises()
+  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,local')
+  let resolveClient!:(value:unknown)=>void
+  readFrame=key=>key==='client-mac'?new Promise(resolve=>{resolveClient=resolve}):screenshot(key)
+  await wrapper.get('[aria-label="显示的桌面"]').setValue('desktop:client-mac')
+  await flushPromises()
+  expect(wrapper.find('.preview img').exists()).toBe(false)
+  expect(wrapper.get('.preview').attributes('disabled')).toBeDefined()
+  await wrapper.get('[aria-label="显示的桌面"]').setValue('cloud');await flushPromises()
+  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,cloud')
+  resolveClient(screenshot('stale-client'));await flushPromises()
+  expect(wrapper.get('.preview img').attributes('src')).toContain('base64,cloud')
+  await wrapper.get('.preview').trigger('click');await flushPromises()
+  expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,'cloud',undefined])
+  readFrame=screenshot
+  for(const [key,backend,host] of [['desktop:local','desktop','local'],['desktop:client-mac','desktop','client-mac'],['vm','vm',undefined]]){
+   await wrapper.get('[aria-label="显示的桌面"]').setValue(key);await flushPromises()
+   expect(wrapper.get('.preview img').attributes('src')).toContain('base64,'+(host||backend))
+   await wrapper.get('.preview').trigger('click');await flushPromises()
+   expect(wrapper.emitted('desktop')?.at(-1)).toEqual([agent,backend,host])
+  }
+ }finally{wrapper.unmount()}
+})
+
+it('keeps an offline or removed explicit selection instead of opening the server',async()=>{
+ const wrapper=mount(Preview,{props:{agents:[agent],isAdmin:true,active:true},global:{stubs}})
+ try{
+  await flushPromises();await wrapper.get('[aria-label="显示的桌面"]').setValue('desktop:client-mac');await flushPromises()
+  hosts[1].online=false
+  await wrapper.setProps({active:false});await wrapper.setProps({active:true});await flushPromises()
+  expect(wrapper.text()).toContain('这台电脑已离线')
+  expect(wrapper.find('.preview img').exists()).toBe(false)
+  hosts=hosts.slice(0,1)
+  await wrapper.setProps({active:false});await wrapper.setProps({active:true});await flushPromises()
+  expect(wrapper.text()).toContain('所选电脑已不可用')
+  expect(wrapper.get('.preview').attributes('disabled')).toBeDefined()
+  expect(wrapper.emitted('desktop')).toBeUndefined()
+ }finally{wrapper.unmount()}
+})
+
+it.each([
+ ['desktop','local'],['desktop','client-mac'],['cloud',undefined],['vm',undefined],
+] as const)('opens and controls exactly the requested %s/%s target',async(backend,host)=>{
+ const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend,host,standalone:true,autoTake:true},global:{stubs}})
+ try{
+  await flushPromises()
+  const call=vi.mocked(apiRequest).mock.calls.find(([path])=>path.includes('/computer/take'))!
+  const url=new URL(call[0],'http://fixture')
+  expect(url.searchParams.get('backend')).toBe(backend)
+  expect(url.searchParams.get('host')).toBe(host??null)
+  expect(wrapper.get('.computer-screen img').attributes('src')).toContain('base64,'+(host||backend))
+ }finally{wrapper.unmount()}
+})
+
+it('does not auto-take a different computer when the requested host is missing',async()=>{
+ const wrapper=mount(ComputerPanel,{props:{agents:[agent],backend:'desktop',host:'missing-host',standalone:true,autoTake:true},global:{stubs}})
+ try{
+  await flushPromises()
+  expect(wrapper.get('[role="alert"]').text()).toContain('所选电脑不可用')
+  expect(vi.mocked(apiRequest).mock.calls.some(([path])=>path.includes('/computer/take'))).toBe(false)
+  expect(wrapper.find('.computer-screen img').exists()).toBe(false)
+ }finally{wrapper.unmount()}
+})
+
+it('passes the native window URL target into the viewer and rejects malformed targets',async()=>{
+ route.query={backend:'desktop',host:'client-mac'}
+ const wrapper=mount(Viewer,{global:{stubs:{ComputerPanel:true}}})
+ try{
+  await flushPromises()
+  expect(wrapper.getComponent(ComputerPanel).props()).toMatchObject({backend:'desktop',host:'client-mac',autoTake:true})
+ }finally{wrapper.unmount()}
+ route.query={backend:'vm',host:'client-mac'}
+ const invalid=mount(Viewer,{global:{stubs:{ComputerPanel:true}}})
+ try{
+  await flushPromises();expect(invalid.text()).toContain('所选电脑主机无效')
+  expect(invalid.findComponent(ComputerPanel).exists()).toBe(false)
+ }finally{invalid.unmount()}
+})