// ─── 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;