|
|
@@ -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>
|