338 lines
13 KiB
TypeScript
338 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|