feat(hololake): ship language platform 0.7.0
This commit is contained in:
parent
a360982a9b
commit
5105ec5e32
47 changed files with 4566 additions and 398 deletions
|
|
@ -0,0 +1,338 @@
|
|||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface ServerProfile {
|
||||
id: string;
|
||||
physicalNodeId?: string;
|
||||
name: string;
|
||||
purpose?: 'personal-fifth-domain' | 'enterprise-lighthouse';
|
||||
connected: boolean;
|
||||
verified: boolean;
|
||||
}
|
||||
|
||||
interface ServerSession {
|
||||
authenticated: boolean;
|
||||
nodeId: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
interface ServerRepository {
|
||||
name: string;
|
||||
fullName: string;
|
||||
private: boolean;
|
||||
defaultBranch: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
apiBase: string;
|
||||
currentRemote?: string | null;
|
||||
initialMode?: 'local' | 'server';
|
||||
onClose: () => void;
|
||||
onApplied: (result: { mode: 'local' | 'server'; repository?: ServerRepository }) => void;
|
||||
}
|
||||
|
||||
type ServerBridge = {
|
||||
list: () => Promise<ServerProfile[]>;
|
||||
connect: (nodeId: string) => Promise<ServerProfile>;
|
||||
session: (nodeId?: string) => Promise<ServerSession>;
|
||||
login: (input: { nodeId: string; username: string; password: string }) => Promise<ServerSession>;
|
||||
logout: () => Promise<ServerSession>;
|
||||
repositories: () => Promise<ServerRepository[]>;
|
||||
createRepository: (input: { name: string; description?: string }) => Promise<ServerRepository>;
|
||||
gitRemote: (fullName: string) => Promise<string>;
|
||||
};
|
||||
|
||||
function bridge(): ServerBridge | null {
|
||||
return (window as any).hololake?.server || null;
|
||||
}
|
||||
|
||||
export function StorageLocationSheet({ open, apiBase, currentRemote, initialMode, onClose, onApplied }: Props) {
|
||||
const [mode, setMode] = useState<'local' | 'server'>(currentRemote ? 'server' : 'local');
|
||||
const [servers, setServers] = useState<ServerProfile[]>([]);
|
||||
const [selectedNode, setSelectedNode] = useState('');
|
||||
const [session, setSession] = useState<ServerSession>({ authenticated: false, nodeId: selectedNode });
|
||||
const [repositories, setRepositories] = useState<ServerRepository[]>([]);
|
||||
const [selectedRepository, setSelectedRepository] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newRepository, setNewRepository] = useState('heartbeat-core-knowledge');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const selectedServer = useMemo(
|
||||
() => servers.find(server => server.id === selectedNode) || null,
|
||||
[servers, selectedNode],
|
||||
);
|
||||
|
||||
async function loadRepositories() {
|
||||
const server = bridge();
|
||||
if (!server) return;
|
||||
const next = await server.repositories();
|
||||
setRepositories(next);
|
||||
setSelectedRepository(previous => previous || next[0]?.fullName || '');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setMode(initialMode || (currentRemote ? 'server' : 'local'));
|
||||
setMessage('');
|
||||
const server = bridge();
|
||||
if (!server) {
|
||||
setMessage('服务器托管只在 HoloLake 桌面 App 中提供');
|
||||
return;
|
||||
}
|
||||
server.list()
|
||||
.then(async profiles => {
|
||||
setServers(profiles);
|
||||
const personal = profiles.find(profile => profile.purpose === 'personal-fifth-domain');
|
||||
const nodeId = selectedNode || personal?.id || '';
|
||||
setSelectedNode(nodeId);
|
||||
const currentSession = await server.session(nodeId);
|
||||
setSession(currentSession);
|
||||
if (currentSession.authenticated) {
|
||||
setUsername(currentSession.username || '');
|
||||
await loadRepositories();
|
||||
}
|
||||
})
|
||||
.catch(error => setMessage(error instanceof Error ? error.message : String(error)));
|
||||
}, [open, currentRemote, initialMode]);
|
||||
|
||||
async function selectServer(nodeId: string) {
|
||||
const server = bridge();
|
||||
setSelectedNode(nodeId);
|
||||
setRepositories([]);
|
||||
setSelectedRepository('');
|
||||
setPassword('');
|
||||
setMessage('');
|
||||
if (!server) return;
|
||||
try {
|
||||
const nextSession = await server.session(nodeId);
|
||||
setSession(nextSession);
|
||||
if (nextSession.authenticated) {
|
||||
setUsername(nextSession.username || '');
|
||||
await loadRepositories();
|
||||
} else {
|
||||
setUsername('');
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function connectServer() {
|
||||
const server = bridge();
|
||||
if (!server) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const profile = await server.connect(selectedNode);
|
||||
setServers(previous => previous.map(item => item.id === profile.id ? profile : item));
|
||||
setMessage('服务器连接已验证');
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const server = bridge();
|
||||
if (!server) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const nextSession = await server.login({ nodeId: selectedNode, username, password });
|
||||
setPassword('');
|
||||
setSession(nextSession);
|
||||
await loadRepositories();
|
||||
setMessage(`已登录 ${nextSession.username}`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRepository() {
|
||||
const server = bridge();
|
||||
if (!server || !newRepository.trim()) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const created = await server.createRepository({
|
||||
name: newRepository.trim(),
|
||||
description: 'HoloLake 频道知识库',
|
||||
});
|
||||
setRepositories(previous => [...previous, created].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setSelectedRepository(created.fullName);
|
||||
setCreating(false);
|
||||
setMessage(`已建立私有仓库 ${created.fullName}`);
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const server = bridge();
|
||||
if (!server) return;
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const nextSession = await server.logout();
|
||||
setSession(nextSession);
|
||||
setRepositories([]);
|
||||
setSelectedRepository('');
|
||||
setUsername('');
|
||||
setMode('local');
|
||||
onApplied({ mode: 'local' });
|
||||
setMessage('已退出服务器账号,并解除当前知识库的远端连接');
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (mode === 'local') {
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
if (currentRemote) {
|
||||
const response = await fetch(`${apiBase}/api/forgejo/remote`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (!data.ok) throw new Error(data.error || '解除服务器托管失败');
|
||||
}
|
||||
onApplied({ mode: 'local' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const server = bridge();
|
||||
const repository = repositories.find(item => item.fullName === selectedRepository);
|
||||
if (!server || !repository) {
|
||||
setMessage(session.authenticated ? '请选择目标仓库' : '请先登录服务器');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const remote = await server.gitRemote(repository.fullName);
|
||||
const response = await fetch(`${apiBase}/api/forgejo/remote`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: remote }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.ok) throw new Error(data.error || '连接仓库失败');
|
||||
onApplied({ mode: 'server', repository });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="storage-sheet-backdrop" role="presentation" onMouseDown={event => {
|
||||
if (event.target === event.currentTarget) onClose();
|
||||
}}>
|
||||
<section className="storage-sheet" role="dialog" aria-modal="true" aria-labelledby="storage-sheet-title">
|
||||
<header className="storage-sheet-header">
|
||||
<h2 id="storage-sheet-title">知识库保存位置</h2>
|
||||
<button className="icon-button" onClick={onClose} aria-label="关闭保存位置设置">×</button>
|
||||
</header>
|
||||
|
||||
<div className="storage-mode-list">
|
||||
<label className={`storage-mode-row ${mode === 'local' ? 'selected' : ''}`}>
|
||||
<input type="radio" checked={mode === 'local'} onChange={() => setMode('local')} />
|
||||
<span><strong>仅保存在本机</strong><small>使用本地 Git 保留历史,不上传服务器</small></span>
|
||||
</label>
|
||||
<label className={`storage-mode-row ${mode === 'server' ? 'selected' : ''}`}>
|
||||
<input type="radio" checked={mode === 'server'} onChange={() => setMode('server')} />
|
||||
<span><strong>托管到我的服务器</strong><small>本机保留工作副本,提交到已授权的代码频道</small></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{mode === 'server' && (
|
||||
<div className="storage-server-area">
|
||||
<p className="sheet-label">已选择的服务器</p>
|
||||
<div className="server-profile-picker">
|
||||
<label className="sheet-label" htmlFor="hololake-server-profile">服务器身份</label>
|
||||
<select id="hololake-server-profile" value={selectedNode} onChange={event => selectServer(event.target.value)}>
|
||||
{servers.map(server => (
|
||||
<option key={server.id} value={server.id}>{server.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="server-profile-row">
|
||||
<div className="server-profile-mark" aria-hidden="true"><span /></div>
|
||||
<div><strong>{selectedServer?.name || '我的第五域服务器'}</strong><small>{selectedNode}{selectedServer?.physicalNodeId && selectedServer.physicalNodeId !== selectedNode ? ` · ${selectedServer.physicalNodeId}` : ''}</small></div>
|
||||
<span className="verified-state">{selectedServer?.connected ? '已连接' : '已验证'}</span>
|
||||
{!selectedServer?.connected && <button className="text-button" onClick={connectServer} disabled={busy}>连接</button>}
|
||||
</div>
|
||||
|
||||
{!session.authenticated ? (
|
||||
<div className="server-login-form">
|
||||
<p className="sheet-label">登录服务器代码频道</p>
|
||||
<div className="login-fields">
|
||||
<input value={username} onChange={event => setUsername(event.target.value)} placeholder="账号" autoComplete="username" />
|
||||
<input type="password" value={password} onChange={event => setPassword(event.target.value)} placeholder="密码" autoComplete="current-password" onKeyDown={event => {
|
||||
if (event.key === 'Enter') login();
|
||||
}} />
|
||||
<button className="secondary-button" onClick={login} disabled={busy || !username || !password}>登录</button>
|
||||
</div>
|
||||
<small>密码只用于换取 HoloLake 应用令牌,不写入知识库或 Git 地址。</small>
|
||||
</div>
|
||||
) : (
|
||||
<div className="repository-picker">
|
||||
<div className="repository-picker-heading">
|
||||
<p className="sheet-label">目标仓库</p>
|
||||
<span>已登录 {session.username} <button className="inline-logout" onClick={logout} disabled={busy}>退出账号</button></span>
|
||||
</div>
|
||||
<div className="repository-picker-row">
|
||||
<select value={selectedRepository} onChange={event => setSelectedRepository(event.target.value)}>
|
||||
<option value="">选择私有仓库</option>
|
||||
{repositories.map(repository => (
|
||||
<option key={repository.fullName} value={repository.fullName}>{repository.fullName}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="text-button" onClick={() => setCreating(!creating)}>新建仓库</button>
|
||||
</div>
|
||||
{creating && (
|
||||
<div className="new-repository-row">
|
||||
<input value={newRepository} onChange={event => setNewRepository(event.target.value)} placeholder="仓库名称" />
|
||||
<button className="secondary-button" onClick={createRepository} disabled={busy}>建立私有仓库</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="storage-privacy-note">首次上传前会显示文件数量、目标仓库和分支,需要确认后才会推送。</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && <div className="sheet-message" role="status">{message}</div>}
|
||||
|
||||
<footer className="storage-sheet-footer">
|
||||
<span className="storage-boundary-copy">账号与仓库按服务器隔离</span>
|
||||
<span />
|
||||
<button className="secondary-button" onClick={onClose}>取消</button>
|
||||
<button className="primary-button" onClick={apply} disabled={busy || (mode === 'server' && !session.authenticated)}>
|
||||
{busy ? '正在处理…' : '连接并继续'}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue