|
@@ -5,9 +5,12 @@ import {createUuid} from '@/utils/id'
|
|
|
import type {WorkspaceAgent} from '@shared/workspace'
|
|
import type {WorkspaceAgent} from '@shared/workspace'
|
|
|
import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
|
|
import type {ComputerControlStatus,ComputerFrame,ComputerInput} from '@shared/computerControl'
|
|
|
import type {JsonValue} from '@shared/types'
|
|
import type {JsonValue} from '@shared/types'
|
|
|
-const props=defineProps<{agents:WorkspaceAgent[]}>(),emit=defineEmits<{close:[]}>()
|
|
|
|
|
|
|
+const props=defineProps<{agents:WorkspaceAgent[]}>(),emit=defineEmits<{close:[];changed:[]}>()
|
|
|
const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
|
|
const selected=ref(props.agents[0]?.id??''),dialog=ref<HTMLDialogElement>()
|
|
|
const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
|
|
const state=ref<ComputerControlStatus>({mode:'off'}),frame=ref<ComputerFrame>(),ticket=ref<{controlId:string;token:string}>()
|
|
|
|
|
+const managing=ref(false),shareName=ref('团队共享电脑'),shareMembers=ref<string[]>([]),trusted=ref(false)
|
|
|
|
|
+const selectedAgent=computed(()=>props.agents.find(agent=>agent.id===selected.value))
|
|
|
|
|
+const candidates=computed(()=>props.agents.filter(agent=>!agent.temporaryGoalId&&!agent.computerEnvironmentId&&!agent.archived))
|
|
|
const error=ref(''),busy=ref(false),text=ref(''),notes=ref('')
|
|
const error=ref(''),busy=ref(false),text=ref(''),notes=ref('')
|
|
|
let takeRequestId:string|undefined
|
|
let takeRequestId:string|undefined
|
|
|
let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
|
|
let timer:ReturnType<typeof setTimeout>|undefined,closed=false,polling=false,lastRenew=0,generation=0
|
|
@@ -47,6 +50,9 @@ function click(event:MouseEvent){
|
|
|
const rect=(event.currentTarget as HTMLImageElement).getBoundingClientRect()
|
|
const rect=(event.currentTarget as HTMLImageElement).getBoundingClientRect()
|
|
|
send({kind:'click',x:Math.min(frame.value.width-1,Math.max(0,Math.floor((event.clientX-rect.left)*frame.value.width/rect.width))),y:Math.min(frame.value.height-1,Math.max(0,Math.floor((event.clientY-rect.top)*frame.value.height/rect.height))),button:event.button===2?'right':'left',count:event.detail>1?2:1})
|
|
send({kind:'click',x:Math.min(frame.value.width-1,Math.max(0,Math.floor((event.clientX-rect.left)*frame.value.width/rect.width))),y:Math.min(frame.value.height-1,Math.max(0,Math.floor((event.clientY-rect.top)*frame.value.height/rect.height))),button:event.button===2?'right':'left',count:event.detail>1?2:1})
|
|
|
}
|
|
}
|
|
|
|
|
+function manage(){managing.value=!managing.value;shareMembers.value=candidates.value.map(agent=>agent.id);trusted.value=false}
|
|
|
|
|
+function share(){void run(async()=>{await apiRequest('/api/app/computers/shared',{method:'POST',body:{requestId:createUuid(),name:shareName.value,memberIds:shareMembers.value,trusted:trusted.value}});managing.value=false;frame.value=undefined;state.value={mode:'off'};takeRequestId=undefined;emit('changed')})}
|
|
|
|
|
+function unshare(){const id=selectedAgent.value?.computerEnvironmentId;if(!id)return;void run(async()=>{await apiRequest(`/api/app/computers/shared/${encodeURIComponent(id)}`,{method:'DELETE'});managing.value=false;frame.value=undefined;state.value={mode:'off'};takeRequestId=undefined;emit('changed')})}
|
|
|
async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
|
|
async function giveBack(){if(ticket.value){await post('/giveback',{...ticket.value,notes:notes.value});ticket.value=undefined;takeRequestId=undefined;notes.value=''}}
|
|
|
async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;dialog.value?.close();emit('close')}}
|
|
async function close(){if(busy.value)return;await run(giveBack);if(!ticket.value){closed=true;dialog.value?.close();emit('close')}}
|
|
|
async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
|
|
async function cycle(){await refresh();if(!closed)timer=setTimeout(()=>void cycle(),1200)}
|
|
@@ -70,6 +76,21 @@ onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
|
|
|
</div>
|
|
</div>
|
|
|
<p v-if="error" class="computer-error" role="alert">{{ error }}</p>
|
|
<p v-if="error" class="computer-error" role="alert">{{ error }}</p>
|
|
|
<footer>
|
|
<footer>
|
|
|
|
|
+ <p v-if="selectedAgent?.computerEnvironmentId">共享电脑:{{ selectedAgent.computerEnvironmentName }}。成员共用工作文件和浏览器资料,同一时间只有一名操作者。</p>
|
|
|
|
|
+ <button v-if="!ticket&&(candidates.length>1||selectedAgent?.computerEnvironmentId)" type="button" :disabled="busy" @click="manage">{{ managing?'返回电脑':'电脑共享设置' }}</button>
|
|
|
|
|
+ <section v-if="managing" class="sharing-settings" aria-label="电脑共享设置">
|
|
|
|
|
+ <template v-if="selectedAgent?.computerEnvironmentId">
|
|
|
|
|
+ <p>解除共享后,成员回到各自原有的独立电脑;共享工作区保留,可由执行节点备份或恢复。</p>
|
|
|
|
|
+ <button type="button" :disabled="busy" @click="unshare">解除这台电脑的共享</button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ <template v-else>
|
|
|
|
|
+ <label>共享电脑名称<input v-model="shareName" maxlength="100"></label>
|
|
|
|
|
+ <fieldset><legend>选择可信成员(同一来源和 Profile)</legend><label v-for="agent in candidates" :key="agent.id" class="share-option"><input v-model="shareMembers" type="checkbox" :value="agent.id">{{ agent.name }} · {{ agent.profile }}</label></fieldset>
|
|
|
|
|
+ <p>新建共享工作区,原独立电脑的文件仍然保留。请先停止所选成员的任务并交还电脑控制权。</p>
|
|
|
|
|
+ <label class="share-option"><input v-model="trusted" type="checkbox">我确认这些成员可以访问同一工作区和浏览器登录资料</label>
|
|
|
|
|
+ <button type="button" :disabled="busy||!trusted||shareMembers.length<2||shareMembers.length>8||!shareName.trim()" @click="share">创建可信共享电脑</button>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </section>
|
|
|
<p v-if="!ticket">查看不会输入任何内容。接管后 Agent 会暂停,已有操作结束后才允许人工操作。</p>
|
|
<p v-if="!ticket">查看不会输入任何内容。接管后 Agent 会暂停,已有操作结束后才允许人工操作。</p>
|
|
|
<button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="take">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
|
|
<button v-if="!ticket&&state.mode!=='human'" type="button" :disabled="busy" @click="take">{{ state.mode==='off'?'打开并控制':'接管电脑' }}</button>
|
|
|
<template v-if="mine">
|
|
<template v-if="mine">
|
|
@@ -87,8 +108,9 @@ onBeforeUnmount(()=>{closed=true;generation++;clearTimeout(timer)})
|
|
|
<style scoped>
|
|
<style scoped>
|
|
|
.computer-panel{width:min(1120px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary);overflow:auto}
|
|
.computer-panel{width:min(1120px,calc(100vw - 32px));max-height:calc(100dvh - 32px);padding:0;border:1px solid var(--line);border-radius:16px;background:var(--surface);color:var(--text-primary);overflow:auto}
|
|
|
.computer-panel::backdrop{background:rgba(0,0,0,.45)}header{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--line)}header div{display:grid;gap:4px;margin-right:auto}small{font-size:12px;color:var(--text-muted);line-height:1.5}
|
|
.computer-panel::backdrop{background:rgba(0,0,0,.45)}header{display:flex;align-items:center;gap:14px;padding:14px 18px;border-bottom:1px solid var(--line)}header div{display:grid;gap:4px;margin-right:auto}small{font-size:12px;color:var(--text-muted);line-height:1.5}
|
|
|
-button,select,textarea{font:inherit;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised);color:var(--text-primary)}button,select{min-height:44px;padding:8px 12px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
|
|
|
|
|
+button,select,textarea,input{font:inherit;border:1px solid var(--line);border-radius:8px;background:var(--surface-raised);color:var(--text-primary)}button,select{min-height:44px;padding:8px 12px}button{cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,select:focus-visible,textarea:focus-visible,input:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
|
.computer-screen{position:relative;display:flex;align-items:center;justify-content:center;min-height:220px;background:#14181e}.computer-screen img{max-width:100%;max-height:58vh;width:auto;height:auto;user-select:none}.controlling img{cursor:crosshair}.computer-screen p{color:#d8dee7}.view-label{position:absolute;top:10px;left:12px;padding:4px 8px;border-radius:6px;background:rgba(0,0,0,.65);color:white;font-size:12px;pointer-events:none}
|
|
.computer-screen{position:relative;display:flex;align-items:center;justify-content:center;min-height:220px;background:#14181e}.computer-screen img{max-width:100%;max-height:58vh;width:auto;height:auto;user-select:none}.controlling img{cursor:crosshair}.computer-screen p{color:#d8dee7}.view-label{position:absolute;top:10px;left:12px;padding:4px 8px;border-radius:6px;background:rgba(0,0,0,.65);color:white;font-size:12px;pointer-events:none}
|
|
|
footer{padding:16px 18px;display:grid;gap:12px}footer p{margin:0;font-size:13px;color:var(--text-secondary);line-height:1.6}label{display:grid;gap:6px;font-size:13px}textarea{padding:10px;min-width:0;resize:vertical}.key-row{display:flex;gap:6px;flex-wrap:wrap}.computer-error{color:var(--danger);padding:0 18px;font-size:13px}
|
|
footer{padding:16px 18px;display:grid;gap:12px}footer p{margin:0;font-size:13px;color:var(--text-secondary);line-height:1.6}label{display:grid;gap:6px;font-size:13px}textarea{padding:10px;min-width:0;resize:vertical}.key-row{display:flex;gap:6px;flex-wrap:wrap}.computer-error{color:var(--danger);padding:0 18px;font-size:13px}
|
|
|
|
|
+.sharing-settings{display:grid;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px}.sharing-settings input:not([type=checkbox]){min-height:44px;padding:8px 10px}.share-option{display:flex;align-items:center;gap:8px;min-height:44px}.share-option input{width:18px;height:18px;flex:none}fieldset{border:1px solid var(--line);border-radius:8px}
|
|
|
@media(max-width:600px){.computer-panel{width:100vw;max-height:100dvh;border-radius:0}header{flex-wrap:wrap;padding:12px}footer{padding:12px}.computer-screen img{max-height:48vh}}
|
|
@media(max-width:600px){.computer-panel{width:100vw;max-height:100dvh;border-radius:0}header{flex-wrap:wrap;padding:12px}footer{padding:12px}.computer-screen img{max-height:48vh}}
|
|
|
</style>
|
|
</style>
|