// ─── Files screen ─── Real file ops against /api/instance/files* function FilesScreen({ setScreen, toast }) { const [path, setPath] = React.useState('/'); const [entries, setEntries] = React.useState([]); const [loading, setLoading] = React.useState(false); const [selected, setSelected] = React.useState(null); const [uploadLoading, setUploadLoading] = React.useState(false); const [maxMB, setMaxMB] = React.useState(20); const fileInput = React.useRef(null); React.useEffect(() => { apiFetch('/api/auth/config').then(r => r.json()).then(d => { const n = Number(d.file_upload_max_mb); if (n > 0) setMaxMB(n); }).catch(() => {}); }, []); async function load(p) { setLoading(true); try { const data = await apiRequest(`/api/instance/files?path=${encodeURIComponent(p)}`); setPath(data.path || '/'); const sorted = (data.entries || []).slice().sort((a, b) => { if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; return a.name.localeCompare(b.name); }); setEntries(sorted); setSelected(null); } catch (ex) { toast(errText(ex.code || ex.message), 'error'); setEntries([]); } finally { setLoading(false); } } React.useEffect(() => { load('/'); }, []); // eslint-disable-line function entryPath(e) { const base = path === '/' ? '' : path; return `${base}/${e.name}`; } function clickEntry(e) { setSelected(e.name); if (e.is_dir) load(entryPath(e)); } function navigateUp() { const parts = path.split('/').filter(Boolean); parts.pop(); load('/' + parts.join('/')); } function crumbs() { const parts = path.split('/').filter(Boolean); const out = [{ name: 'workspace', p: '/' }]; let acc = ''; for (const pt of parts) { acc += '/' + pt; out.push({ name: pt, p: acc }); } return out; } function download(e) { const url = `/api/instance/files/download?path=${encodeURIComponent(entryPath(e))}`; const a = document.createElement('a'); a.href = url; a.download = e.name; document.body.appendChild(a); a.click(); document.body.removeChild(a); } async function remove(e) { const msg = e.is_dir ? `确定删除目录 ${e.name} 及其所有内容吗?` : `确定删除 ${e.name} 吗?`; if (!confirm(msg)) return; try { const res = await apiFetch(`/api/instance/files?path=${encodeURIComponent(entryPath(e))}`, { method: 'DELETE' }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || 'err_file_op_failed'); } toast('已删除'); load(path); } catch (ex) { toast(errText(ex.message), 'error'); } } async function upload(ev) { const file = ev.target.files?.[0]; if (!file) return; ev.target.value = ''; if (file.size > maxMB * 1024 * 1024) { toast(`文件超过 ${maxMB}MB 限制`, 'error'); return; } setUploadLoading(true); try { const fd = new FormData(); fd.append('file', file); const res = await apiFetch(`/api/instance/files/upload?path=${encodeURIComponent(path)}`, { method: 'POST', body: fd, headers: { 'Content-Type': null }, }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || 'err_upload_failed'); } toast('上传成功'); load(path); } catch (ex) { toast(errText(ex.message), 'error'); } finally { setUploadLoading(false); } } async function mkdir() { const raw = prompt('输入新目录名称'); if (raw === null) return; const name = raw.trim(); if (!name || name.includes('/')) { toast('目录名不能为空,且不能包含 /', 'error'); return; } try { const res = await apiFetch(`/api/instance/files/dirs?path=${encodeURIComponent(path)}`, { method: 'POST', body: JSON.stringify({ name }), }); if (!res.ok) { const d = await res.json().catch(() => ({})); throw new Error(d.error || 'err_file_op_failed'); } toast('目录已创建'); load(path); } catch (ex) { toast(errText(ex.message), 'error'); } } const usedMB = entries.filter(e => !e.is_dir).reduce((s, e) => s + (e.size || 0), 0) / (1024 * 1024); return (
file_browser
/workspace · 本目录 {usedMB.toFixed(1)}M · 上传上限 {maxMB}M
名称 类型 大小 修改时间
{path !== '/' && (
.. 上级目录
)} {loading && (
加载中...
)} {!loading && entries.length === 0 && (
{'// 空目录'}
)} {!loading && entries.map(e => (
clickEntry(e)} onDoubleClick={() => !e.is_dir && download(e)} > {e.name} {e.is_dir ? '目录' : kindFromName(e.name)} {e.is_dir ? '—' : formatSize(e.size)} {relTime(e.mod_time)} {!e.is_dir && ( ev.stopPropagation()}> )} {e.is_dir && ( ev.stopPropagation()} style={{ marginLeft: 8, display: 'inline-flex' }}> )}
))}
{entries.length} 项{selected ? ` · 已选 ${selected}` : ''} {'>'} 双击文件下载 · 上传上限 {maxMB} MB
); } window.FilesScreen = FilesScreen; function kindFromName(name) { const i = name.lastIndexOf('.'); if (i < 0) return '文件'; const ext = name.slice(i + 1).toLowerCase(); return ext.toUpperCase(); } function formatSize(bytes) { if (!bytes) return '0'; const units = ['B', 'KB', 'MB', 'GB']; let i = 0, s = bytes; while (s >= 1024 && i < units.length - 1) { s /= 1024; i++; } return s.toFixed(i > 0 ? 1 : 0) + ' ' + units[i]; } function relTime(iso) { if (!iso) return '—'; const dt = new Date(iso); if (Number.isNaN(dt.getTime())) return '—'; const diff = Date.now() - dt.getTime(); if (diff < 60_000) return '刚刚'; if (diff < 3_600_000) return Math.floor(diff / 60_000) + 'm 前'; if (diff < 86_400_000) return Math.floor(diff / 3_600_000) + 'h 前'; if (diff < 7 * 86_400_000) return Math.floor(diff / 86_400_000) + 'd 前'; const pad = n => String(n).padStart(2, '0'); return `${pad(dt.getMonth()+1)}-${pad(dt.getDate())}`; } // ─── Terminal screen ─── xterm.js + binary WebSocket, mirrors web/hosting-ui-v4/src/components/Terminal.vue // Protocol: [tag][payload] // 0x00: bi-directional data (stdin client→server, stdout server→client) // 0x01: resize (client→server) — [0x01, cols_hi, cols_lo, rows_hi, rows_lo] big-endian // 0x7e (~): info message (server→client) // 0x7f (DEL): error/exit (server→client), body is "code:message" const TERM_ERROR_MESSAGES = { err_instance_not_found: '请先创建云端电脑', err_instance_not_ready: '实例未运行,请先启动', err_container_failed: '终端启动失败,请重试', session_replaced: '已在其他位置打开此终端', idle_timeout: '空闲超时,终端已关闭', container_stopped: '容器已停止', }; function TermScreen({ setScreen }) { const hostRef = React.useRef(null); const termRef = React.useRef(null); const wsRef = React.useRef(null); const fitRef = React.useRef(null); const [status, setStatus] = React.useState({ text: '', kind: '' }); function buildWsURL(cols, rows) { const u = new URL('/api/instance/terminal', window.location.origin); u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:'; u.searchParams.set('cols', String(cols)); u.searchParams.set('rows', String(rows)); return u.toString(); } function sendResize(cols, rows) { const ws = wsRef.current; if (!ws || ws.readyState !== WebSocket.OPEN) return; const frame = new Uint8Array(5); frame[0] = 0x01; const dv = new DataView(frame.buffer); dv.setUint16(1, cols, false); dv.setUint16(3, rows, false); ws.send(frame); } function connect() { if (!window.Terminal || !window.FitAddon) { setStatus({ text: 'xterm 未加载', kind: 'error' }); return; } setStatus({ text: '', kind: '' }); const term = new window.Terminal({ cursorBlink: true, fontFamily: '"JetBrains Mono", ui-monospace, Menlo, monospace', fontSize: 13, convertEol: false, theme: { background: '#0e0e14', foreground: '#d8ffcd', cursor: '#ff5b7a' }, }); const FitCtor = window.FitAddon.FitAddon || window.FitAddon; const fit = new FitCtor(); term.loadAddon(fit); term.open(hostRef.current); setTimeout(() => { try { fit.fit(); } catch {} }, 0); termRef.current = term; fitRef.current = fit; const ws = new WebSocket(buildWsURL(term.cols, term.rows)); ws.binaryType = 'arraybuffer'; wsRef.current = ws; ws.onopen = () => sendResize(term.cols, term.rows); ws.onmessage = (ev) => { const view = new Uint8Array(ev.data); if (view.length === 0) return; const tag = view[0]; const payload = view.subarray(1); if (tag === 0x00) { term.write(payload); } else if (tag === 0x7e) { setStatus({ text: new TextDecoder().decode(payload), kind: 'info' }); } else if (tag === 0x7f) { const body = new TextDecoder().decode(payload); const code = body.split(':', 1)[0]; setStatus({ text: TERM_ERROR_MESSAGES[code] || body, kind: 'error' }); } }; ws.onclose = () => { setStatus(s => s.kind ? s : { text: '会话已结束', kind: 'error' }); }; ws.onerror = () => { setStatus(s => s.kind ? s : { text: '连接失败', kind: 'error' }); }; term.onData((data) => { const w = wsRef.current; if (!w || w.readyState !== WebSocket.OPEN) return; const bytes = new TextEncoder().encode(data); const frame = new Uint8Array(bytes.length + 1); frame[0] = 0x00; frame.set(bytes, 1); w.send(frame); }); } function disconnect() { const ws = wsRef.current; if (ws) { try { ws.close(); } catch {} wsRef.current = null; } const term = termRef.current; if (term) { term.dispose(); termRef.current = null; } fitRef.current = null; } function reconnect() { disconnect(); connect(); } React.useEffect(() => { connect(); let resizeTimer = null; const onResize = () => { clearTimeout(resizeTimer); resizeTimer = setTimeout(() => { const fit = fitRef.current; const term = termRef.current; if (!fit || !term) return; try { fit.fit(); } catch {} sendResize(term.cols, term.rows); }, 150); }; window.addEventListener('resize', onResize); return () => { window.removeEventListener('resize', onResize); clearTimeout(resizeTimer); disconnect(); }; }, []); // eslint-disable-line return (
{status.kind === 'error' && ( )}
pty · /bin/bash
{status.kind === 'error' ? 'disconnected' : 'connected'}
{status.text && (
{status.kind === 'error' ? '⚠ ' : 'ⓘ '}{status.text}
)}
); } window.TermScreen = TermScreen;