Przeglądaj źródła

对齐镜像状态引导并记录移动端全屏展示验收

LSamien 1 miesiąc temu
rodzic
commit
9f0cf23d38

+ 22 - 0
docs/computer-management-ui.md

@@ -36,3 +36,25 @@ Web 流程覆盖常驻入口、未配置提示、应用/回退、导出下载及
 本分支最终单元/集成测试为 775 项通过、6 项按环境跳过;浏览器流程 7 项通过,真实 Worker 流程 2 项通过。下载块只保存命令指纹,每次重试重新鉴权读取,避免在回执数据库中重复存储整个归档;上传块在网络中断或服务器暂时错误时最多重试两次。
 
 手机端源码为 iOS 1.3(189),已签名并安装至 iPhone 13 Pro Max;安装后设备离线,启动核验尚未完成。Android 已通过编译和模拟器界面验证。新版客户端连接旧服务时显示升级提示;本次未替换正式运行中的 Web 服务。
+
+
+## OpenMausBot 展示方式对齐
+
+这轮以 `/Users/samien/git/OpenMausBot` 当前源码为参考,重点是 Bot 上下文、镜像状态和手机画面的摆放。
+
+| 参考实现 | 本分支对应行为 |
+| --- | --- |
+| `ios/App/ChatView.swift` 的右上角电脑入口 | iOS/Android 聊天右上角显示电脑图标。一个桌面直接全屏打开,多个桌面先选择;共享成员指向同一环境时只显示一次。 |
+| `ios/App/ComputerView.swift`、Android `ComputerScreen.kt` | 黑色全屏背景,画面保持原比例并居中,顶部显示 Bot 名称、共享环境和预览/控制状态。 |
+| 手机端主画面底部的桌面操作 | 底部保留接管/交还主按钮;接管后点“键盘与操作”打开面板,提供文字、按键、左右键、滚动和交还说明。 |
+| 按查看生命周期订阅屏幕 | 当前传输沿用已授权的 Runner 帧接口,仅查看时刷新,退出或后台停止轮询;连接失败显示画面已暂停并禁止输入旧帧。 |
+| `src/components/LocalComputerSection.tsx` 的镜像就绪状态 | Web/macOS 镜像页突出当前应用状态,展示“准备镜像 → 应用到节点 → 打开 Bot 电脑”的顺序。 |
+
+聊天列表的“桌面与屏幕”改为顶部图标,不占用聊天内容和输入区。桌面列表使用紧凑的图标行;没有桌面时才展示配置指引。手机依然不管理镜像、共享成员或执行环境。
+
+本轮原生界面使用从兼容镜像中实际截取的 1280×900 XFCE 桌面进行展示验收;交互 API 由隔离测试服务提供。iOS 覆盖聊天右上角入口、独立/共享选择、全屏预览、接管、键盘输入、交还、大字体深色布局。Android 另外验证等比留黑区域不会产生点击,桌面中心正确映射到远端坐标。Web 7 项浏览器流程通过。真实 Runner 的端到端验收记录见上节;本轮未更换屏幕传输协议。
+
+新版手机界面构建号为 iOS 1.3(190)。189 的既有安装记录不代表本轮布局已经覆盖安装,交付时需再次核对设备状态。
+
+
+本轮截图:[聊天右上角入口](screenshots/openmaus-layout/bot-header-computer-entry.png)、[iOS 全屏画面](screenshots/openmaus-layout/computer-readonly.png)、[接管后的操作栏](screenshots/openmaus-layout/computer-immersive-control.png)、[Android 全屏画面](screenshots/openmaus-layout/android-desktop.png)、[镜像状态](screenshots/openmaus-layout/image-overview.png)。

BIN
docs/screenshots/openmaus-layout/android-desktop.png


BIN
docs/screenshots/openmaus-layout/bot-header-computer-entry.png


BIN
docs/screenshots/openmaus-layout/computer-immersive-control.png


BIN
docs/screenshots/openmaus-layout/computer-readonly.png


BIN
docs/screenshots/openmaus-layout/image-overview.png


BIN
docs/screenshots/openmaus-layout/mobile-immersive-large-dark.png


BIN
docs/screenshots/openmaus-layout/mobile-shared-screen.png


+ 3 - 1
src/client/components/common/AppIcon.vue

@@ -48,6 +48,7 @@ type IconName =
   | 'pin-off'
   | 'globe'
   | 'bell'
+  | 'monitor'
 
 withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
 </script>
@@ -65,7 +66,8 @@ withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
     stroke-linejoin="round"
     aria-hidden="true"
   >
-    <template v-if="name === 'chat'">
+    <template v-if="name === 'monitor'"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M8 21h8M12 16v5"/></template>
+    <template v-else-if="name === 'chat'">
       <path d="M8 18h-3a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2h-5l-6 4z" />
       <path d="M8 9h8M8 13h5" />
     </template>

+ 14 - 4
src/client/components/workspace/ImageManagementPanel.vue

@@ -1,12 +1,15 @@
 <script setup lang="ts">
-import {ref,onMounted,onBeforeUnmount} from 'vue'
+import {ref,computed,onMounted,onBeforeUnmount} from 'vue'
+import AppIcon from '@/components/common/AppIcon.vue'
 import {apiRequest} from '@/api/client'
 import {createUuid} from '@/utils/id'
-import type {ImageInventory,ImageOperation,ImageJob} from '@shared/imageManagement'
+import type {ImageInventory,ImageOperation} from '@shared/imageManagement'
 const runners=ref<Array<{id:string;name:string;online:boolean;enabled:boolean}>>([]),selected=ref(''),inventory=ref<ImageInventory>(),error=ref(''),busy=ref(false),loading=ref(false),progress=ref('')
 const archive=ref<File>(),manifest=ref<File>(),uploadId=ref('');let closed=false,timer:ReturnType<typeof setTimeout>,generation=0
 const base=()=>`/api/app/admin/runners/${encodeURIComponent(selected.value)}/images`
 const names:Record<ImageOperation,string>={prepare:'准备兼容镜像',verify:'校验',activate:'应用',rollback:'回退',import:'导入',export:'导出'}
+const activeImage=computed(()=>inventory.value?.images.find(image=>image.imageId===inventory.value?.activeImageId))
+const readiness=computed(()=>error.value?'状态读取失败':inventory.value?.busy?'正在维护镜像':activeImage.value?'桌面镜像已应用':inventory.value?.images.length?'镜像已准备,等待应用':'尚未准备桌面镜像')
 async function refresh(){if(!selected.value||busy.value||closed)return;const current=++generation;loading.value=true;try{const data=await apiRequest<ImageInventory>(base());if(current===generation)inventory.value=data}catch(e){if(current===generation)error.value=e instanceof Error?e.message:'无法读取镜像'}finally{loading.value=false}}
 async function action(operation:ImageOperation,imageId?:string){if(busy.value)return;busy.value=true;error.value='';try{await apiRequest(base()+'/jobs',{method:'POST',body:{requestId:createUuid(),operation,...(imageId?{imageId}:{}),...(operation==='import'?{uploadId:uploadId.value}:{})}})}catch(e){error.value=e instanceof Error?e.message:'镜像操作失败'}finally{busy.value=false;await refresh()}}
 async function sendChunk(endpoint:string,body:{offset:number;data:string}){
@@ -32,13 +35,19 @@ onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
 </script>
 <template>
  <section class="image-manager" aria-label="镜像管理">
-  <p>操作提交后在节点后台继续,可关闭页面稍后查看记录。</p>
-  <p>在执行节点上准备和维护电脑镜像。应用或回退会保留工作区;请先停止该节点的电脑任务并交还控制权。</p>
+  <div class="image-intro"><span class="desktop-icon"><AppIcon name="monitor" :size="24"/></span><div><h3>虚拟桌面镜像</h3><p>为 Bot 准备独立或共享的桌面环境,手机端可查看画面与接管操作。</p></div></div>
   <p v-if="!runners.length&&!error">尚未注册执行节点。请先在“节点配置”中注册并连接 Runner,镜像 ID 可以稍后设置。</p>
   <label v-else>执行节点<select v-model="selected" :disabled="busy||inventory?.busy" @change="inventory=undefined;error='';refresh()"><option v-for="runner in runners" :key="runner.id" :value="runner.id">{{runner.name}}{{runner.online?'':'(离线)'}}</option></select></label>
   <p v-if="error" role="alert">{{error}}</p>
   <template v-if="selected">
+   <section v-if="inventory" class="image-summary" aria-label="桌面镜像状态">
+    <strong>{{readiness}}</strong>
+    <p v-if="activeImage">Linux · {{activeImage.architecture}} · 当前镜像 {{activeImage.imageId.slice(0,19)}}…</p>
+    <p v-else>准备或导入镜像后,点击“应用”供这个节点上的 Bot 使用。</p>
+    <ol class="setup-steps" aria-label="镜像准备流程"><li :class="{done:inventory.images.length}">1 · 准备镜像</li><li :class="{done:activeImage}">2 · 应用到节点</li><li>3 · 打开 Bot 电脑</li></ol>
+   </section>
    <div class="actions"><button :disabled="busy||inventory?.busy" @click="action('prepare')">准备兼容镜像</button><button :disabled="busy||loading" @click="error='';refresh()">刷新</button><button v-if="inventory?.previousImageId" :disabled="busy||inventory?.busy" @click="action('rollback')">回退到上一镜像</button></div>
+   <p>操作在节点后台继续。应用或回退保留工作区;维护前请停止电脑任务并交还控制权。</p>
    <p v-if="loading&&!inventory" role="status">正在读取节点镜像…</p>
    <p v-if="inventory&&!inventory.images.length">节点还没有兼容镜像。可以在线准备,或导入已有镜像和校验清单。</p>
    <article v-for="image in inventory?.images" :key="image.imageId" class="image-card"><strong>{{image.architecture}} · CUA {{image.driver}}</strong><code>{{image.imageId}}</code><span v-if="image.imageId===inventory?.activeImageId">当前使用</span><div class="actions"><button :disabled="busy||inventory?.busy" @click="action('verify',image.imageId)">校验</button><button :disabled="busy||inventory?.busy||image.imageId===inventory?.activeImageId" @click="action('activate',image.imageId)">应用</button><button :disabled="busy||inventory?.busy" @click="action('export',image.imageId)">导出</button></div></article>
@@ -49,5 +58,6 @@ onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
  </section>
 </template>
 <style scoped>
+.image-intro{display:flex;gap:14px;align-items:center}.image-intro h3{margin:0 0 6px;font-size:17px}.desktop-icon{display:grid;place-items:center;width:48px;height:48px;flex:none;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft);color:var(--accent)}.image-summary{display:grid;gap:10px;padding:16px;border:1px solid var(--line);border-radius:12px;background:var(--surface-soft)}.setup-steps{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:4px 0 0;padding:0;font-size:12px;color:var(--text-muted)}.setup-steps li{padding:8px 10px;border:1px solid var(--line);border-radius:8px}.setup-steps .done{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 40%,var(--line))}
 .image-manager,.image-card,fieldset,label{display:grid;gap:12px}.image-card,fieldset{padding:16px;border:1px solid var(--line);border-radius:12px;min-width:0}.actions{display:flex;gap:8px;flex-wrap:wrap}p{font-size:13px;line-height:1.6;margin:0;color:var(--text-secondary)}code{overflow-wrap:anywhere;font-size:12px}button,select,input,a{font:inherit;min-height:44px;color:var(--text-primary)}button,select,a{border:1px solid var(--line);border-radius:8px;padding:8px 12px;background:var(--surface-raised)}a{display:inline-flex;align-items:center;text-decoration:none}button:disabled{opacity:.5}button{cursor:pointer}:is(button,select,input,a):focus-visible{outline:2px solid var(--accent);outline-offset:2px}[role=alert]{color:var(--danger)}
 </style>

+ 2 - 0
tests/e2e/team-tools.spec.ts

@@ -199,6 +199,8 @@ test('always exposes computer management and performs image and sharing actions
  await expect(hub).toBeVisible();await expect(hub.getByText('尚未配置电脑',{exact:true}).first()).toBeVisible()
  await hub.getByRole('button',{name:'镜像',exact:true}).click()
  await expect(hub.getByRole('button',{name:'准备兼容镜像'})).toBeVisible()
+ await expect(hub.getByRole('region',{name:'桌面镜像状态'})).toContainText('桌面镜像已应用')
+ await page.screenshot({path:testInfo.outputPath('image-desktop-overview.png')})
  const rows=hub.locator('.image-card');await expect(rows.first()).toContainText('当前使用')
  await rows.nth(1).getByRole('button',{name:'应用',exact:true}).click()
  await expect(rows.nth(1)).toContainText('当前使用')

+ 5 - 3
tests/fixtures/native-computer.py

@@ -1,9 +1,11 @@
 """Disposable UI fixture; use distinct ports for concurrent native clients."""
-import argparse, base64, json, time, threading, hashlib
+import argparse, base64, json, time, threading, hashlib, struct
 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
 from pathlib import Path
 parser=argparse.ArgumentParser();parser.add_argument('--port',type=int,required=True);parser.add_argument('--image',required=True);args=parser.parse_args()
-image=base64.b64encode(Path(args.image).read_bytes()).decode()
+image_bytes=Path(args.image).read_bytes()
+image=base64.b64encode(image_bytes).decode()
+width,height=struct.unpack('>II',image_bytes[16:24])
 state={'mode':'agent','generation':1,'controlId':None,'actions':[],'notes':''}
 agents=[dict(id='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',name='环境助手甲',avatar='',instructions='',nodeId='local',profile='default',archived=False,revision=1,execution='computer'),dict(id='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',name='环境助手乙',avatar='',instructions='',nodeId='local',profile='default',archived=False,revision=1,execution='computer')]
 agents[1].update(computerEnvironmentId='cccccccc-cccc-4ccc-8ccc-cccccccccccc',computerEnvironmentName='协作电脑')
@@ -23,7 +25,7 @@ class Handler(BaseHTTPRequestHandler):
             if self.path=='/api/app/admin/runners': return self.respond({'runners':[{'id':'11111111-1111-4111-8111-111111111111','name':'测试执行节点','enabled':True,'online':True}]})
             if '/images/jobs/' in self.path and '/chunk' in self.path: return self.respond({'data':base64.b64encode(b'fixture-image').decode(),'offset':0,'size':13,'manifest':{'protocol':1}})
             if self.path.endswith('/images'): return self.respond({'configured':True,'busy':False,'activeImageId':env['image'],'previousImageId':env['previous'],'images':[{'imageId':'sha256:'+'a'*64,'architecture':'arm64','driver':'0.20.0'},{'imageId':'sha256:'+'b'*64,'architecture':'arm64','driver':'0.20.0'}],'jobs':env['jobs'],'uploads':[]})
-            if self.path.endswith('/frame'): return self.respond({'id':'22222222-2222-4222-8222-222222222222','generation':state['generation'],'data':image,'width':512,'height':512,'capturedAt':int(time.time()*1000)})
+            if self.path.endswith('/frame'): return self.respond({'id':'22222222-2222-4222-8222-222222222222','generation':state['generation'],'data':image,'width':width,'height':height,'capturedAt':int(time.time()*1000)})
             if self.path.endswith('/computer'): return self.respond({**state,'canResume':True})
             return self.respond({'ok':True,'csrfToken':'fixture','features':['computerControl']})
     def do_POST(self):