hololake-system-architecture/product-source/guanghu-knowledge-base/src/components/StorageLocationSheet.tsx

338 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}