完善客户端模块与工作区能力
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
inman
2026-08-13 19:45:30 +08:00
parent 0148b91a15
commit 22add3f01f
97 changed files with 4756 additions and 3226 deletions

View File

@@ -2,21 +2,28 @@
* Settings Page
* Makelore application configuration.
*/
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ChevronDown,
ExternalLink,
FileText,
FolderPlus,
Play,
KeyRound,
RefreshCw,
RotateCw,
Square,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { DisclosureContent } from '@/components/ui/disclosure';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
@@ -38,11 +45,6 @@ import { useSettingsStore } from '@/stores/settings';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
function getHealthCheckedLabel(value: number | null): string {
if (!value) return 'Health not checked yet';
return `Health checked ${new Date(value).toLocaleTimeString()}`;
}
export function Settings() {
const { t } = useTranslation('settings');
const navigate = useNavigate();
@@ -64,24 +66,20 @@ export function Settings() {
setProxyAllServer,
setProxyBypassRules,
devModeUnlocked,
setDevModeUnlocked,
unlockDevMode,
lockDevMode,
telemetryEnabled,
setTelemetryEnabled,
} = useSettingsStore();
const opencodeStatus = useOpencodeStore((state) => state.status);
const opencodeHealth = useOpencodeStore((state) => state.health);
const opencodeHealthCheckedAt = useOpencodeStore((state) => state.healthCheckedAt);
const opencodeRuntimeConfigSummary = useOpencodeStore((state) => state.runtimeConfigSummary);
const opencodeProjects = useOpencodeStore((state) => state.projects);
const activeOpencodeProject = useOpencodeStore((state) => state.activeProject);
const opencodeLoading = useOpencodeStore((state) => state.loading);
const opencodeError = useOpencodeStore((state) => state.error);
const refreshOpencodeStatus = useOpencodeStore((state) => state.refreshStatus);
const startOpencodeRuntime = useOpencodeStore((state) => state.start);
const stopOpencodeRuntime = useOpencodeStore((state) => state.stop);
const restartOpencodeRuntime = useOpencodeStore((state) => state.restart);
const checkOpencodeHealth = useOpencodeStore((state) => state.checkHealth);
const loadOpencodeRuntimeConfigSummary = useOpencodeStore(
(state) => state.loadRuntimeConfigSummary
);
@@ -102,6 +100,10 @@ export function Settings() {
const [showTelemetryViewer, setShowTelemetryViewer] = useState(false);
const [telemetryEntries, setTelemetryEntries] = useState<UiTelemetryEntry[]>([]);
const [showAdditionalSettings, setShowAdditionalSettings] = useState(false);
const [adminDialogOpen, setAdminDialogOpen] = useState(false);
const [adminPassword, setAdminPassword] = useState('');
const [adminPasswordError, setAdminPasswordError] = useState<string | null>(null);
const [adminUnlocking, setAdminUnlocking] = useState(false);
const [workspaceProjectError, setWorkspaceProjectError] = useState<string | null>(null);
useEffect(() => {
@@ -146,10 +148,16 @@ export function Settings() {
useEffect(() => {
void Promise.allSettled([
refreshOpencodeStatus(),
]);
}, [refreshOpencodeStatus]);
useEffect(() => {
if (!devModeUnlocked) return;
void Promise.allSettled([
loadOpencodeRuntimeConfigSummary(),
loadOpencodeProjects(),
]);
}, [loadOpencodeProjects, loadOpencodeRuntimeConfigSummary, refreshOpencodeStatus]);
}, [devModeUnlocked, loadOpencodeProjects, loadOpencodeRuntimeConfigSummary]);
const proxySettingsDirty = useMemo(() => {
return (
@@ -213,6 +221,43 @@ export function Settings() {
]);
};
const handleRevealAdditionalSettings = () => {
if (showAdditionalSettings) {
setShowAdditionalSettings(false);
return;
}
if (devModeUnlocked) {
setShowAdditionalSettings(true);
return;
}
setAdminPassword('');
setAdminPasswordError(null);
setAdminDialogOpen(true);
};
const handleUnlockDevMode = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setAdminUnlocking(true);
setAdminPasswordError(null);
try {
const unlocked = await unlockDevMode(adminPassword);
if (!unlocked) {
setAdminPasswordError(t('admin.invalidPassword', 'The administrator password is incorrect.'));
return;
}
setAdminDialogOpen(false);
setAdminPassword('');
setShowAdditionalSettings(true);
} finally {
setAdminUnlocking(false);
}
};
const handleLockDevMode = async () => {
await lockDevMode();
setShowAdditionalSettings(false);
};
const handleAddOpencodeProject = async () => {
const project = await pickAndOpenOpencodeProject();
if (!project) return;
@@ -267,7 +312,7 @@ export function Settings() {
<p className="mt-1 text-sm text-muted-foreground">{t('subtitle')}</p>
</div>
<section className="space-y-5">
<section className="space-y-5" data-testid="settings-general-section">
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-medium">{t('appearance.title')}</h2>
@@ -279,7 +324,7 @@ export function Settings() {
aria-label={t('appearance.showMoreSettings')}
aria-expanded={showAdditionalSettings}
data-testid="settings-reveal-additional"
onClick={() => setShowAdditionalSettings((value) => !value)}
onClick={handleRevealAdditionalSettings}
>
<ChevronDown
className={cn(
@@ -317,7 +362,119 @@ export function Settings() {
</div>
</section>
<DisclosureContent open={showAdditionalSettings} innerClassName="space-y-8">
<section className="space-y-5" data-testid="settings-runtime-summary">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-medium">Runtime</h2>
<p className="text-sm text-muted-foreground">
Check the local runtime status or restart it when needed.
</p>
</div>
<Badge variant={opencodeStatus.state === 'running' ? 'default' : 'secondary'}>
{opencodeStatus.state}
</Badge>
</div>
<div className="flex flex-wrap items-center justify-between gap-4 rounded-lg border bg-card p-4">
<div className="flex min-w-0 flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<div>
<div className="text-xs text-muted-foreground">Status</div>
<div className="font-medium">{opencodeStatus.state}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Port</div>
<div className="font-medium">{opencodeStatus.port}</div>
</div>
</div>
<Button
variant="outline"
onClick={() => void restartOpencodeRuntime()}
disabled={opencodeLoading}
aria-label="Restart runtime"
data-testid="settings-runtime-restart"
>
<RotateCw className={cn('mr-2 h-4 w-4', opencodeLoading && 'animate-spin')} />
Restart runtime
</Button>
</div>
{(opencodeStatus.error || opencodeError) && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{opencodeStatus.error ?? opencodeError}
</div>
)}
</section>
<Separator />
<section className="space-y-5" data-testid="settings-updates">
<div>
<h2 className="text-lg font-medium">{t('updates.title')}</h2>
<p className="text-sm text-muted-foreground">{t('updates.description')}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<UpdateSettings />
</div>
</section>
<Dialog
open={adminDialogOpen}
onOpenChange={(open) => {
setAdminDialogOpen(open);
if (!open) {
setAdminPassword('');
setAdminPasswordError(null);
}
}}
>
<DialogContent data-testid="settings-admin-password-dialog" className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<KeyRound className="h-5 w-5 text-brand" />
{t('admin.title', '管理员验证')}
</DialogTitle>
<DialogDescription>
{t('admin.description', '请输入管理员密码以管理开发者设置。')}
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleUnlockDevMode}>
<div className="space-y-2">
<Label htmlFor="settings-admin-password">{t('admin.password', '管理员密码')}</Label>
<Input
id="settings-admin-password"
type="password"
value={adminPassword}
onChange={(event) => setAdminPassword(event.target.value)}
autoFocus
autoComplete="current-password"
data-testid="settings-admin-password-input"
aria-invalid={Boolean(adminPasswordError)}
/>
{adminPasswordError && (
<p className="text-sm text-destructive" role="alert">
{adminPasswordError}
</p>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setAdminDialogOpen(false)}>
{t('admin.cancel', '取消')}
</Button>
<Button
type="submit"
disabled={!adminPassword.trim() || adminUnlocking}
data-testid="settings-admin-unlock-button"
>
{adminUnlocking && <RefreshCw className="mr-2 h-4 w-4 animate-spin" />}
{t('admin.unlock', '解锁管理设置')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{showAdditionalSettings && devModeUnlocked && (
<DisclosureContent open innerClassName="space-y-8">
<Separator />
<section className="space-y-5" data-testid="settings-opencode-workspace">
@@ -340,123 +497,40 @@ export function Settings() {
</Button>
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<div
className="space-y-4 rounded-lg border bg-card p-4"
data-testid="settings-opencode-runtime"
>
<div className="flex items-start justify-between gap-3">
<div>
<Label className="text-sm font-medium">Runtime</Label>
<p className="text-sm text-muted-foreground">
Local runtime process used by Makelore sessions.
</p>
</div>
<Badge variant={opencodeStatus.state === 'running' ? 'default' : 'secondary'}>
{opencodeStatus.state}
</Badge>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{opencodeStatus.state === 'running' ? (
<>
<Button
variant="outline"
onClick={() => void restartOpencodeRuntime()}
disabled={opencodeLoading}
aria-label="Restart runtime"
>
<RotateCw className="mr-2 h-4 w-4" />
Restart runtime
</Button>
<Button
variant="outline"
onClick={() => void stopOpencodeRuntime()}
disabled={opencodeLoading}
aria-label="Stop runtime"
>
<Square className="mr-2 h-4 w-4" />
Stop runtime
</Button>
</>
) : (
<Button
onClick={() => void startOpencodeRuntime()}
disabled={opencodeLoading}
aria-label="Start runtime"
>
<Play className="mr-2 h-4 w-4" />
Start runtime
</Button>
)}
<Button
variant="outline"
className={opencodeStatus.state === 'running' ? 'sm:col-span-2' : undefined}
onClick={() => void checkOpencodeHealth()}
disabled={opencodeLoading || opencodeStatus.state !== 'running'}
aria-label="Check health"
>
Check health
</Button>
</div>
<div className="grid gap-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Port</span>
<span className="font-medium">{opencodeStatus.port}</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Health</span>
<span className="font-medium">
{opencodeHealth ? (opencodeHealth.ok ? 'Healthy' : 'Unhealthy') : 'Unknown'}
</span>
</div>
<div className="text-xs text-muted-foreground">
{getHealthCheckedLabel(opencodeHealthCheckedAt)}
</div>
{(opencodeStatus.error || opencodeError) && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{opencodeStatus.error ?? opencodeError}
</div>
)}
<div
className="space-y-4 rounded-lg border bg-card p-4"
data-testid="settings-opencode-models"
>
<div className="flex items-start justify-between gap-3">
<div>
<Label className="text-sm font-medium">Models</Label>
<p className="text-sm text-muted-foreground">
Resolved from the runtime configuration.
</p>
</div>
<Button variant="outline" size="sm" onClick={() => navigate('/models')}>
Configure models
</Button>
</div>
<div
className="space-y-4 rounded-lg border bg-card p-4"
data-testid="settings-opencode-models"
>
<div className="flex items-start justify-between gap-3">
<div>
<Label className="text-sm font-medium">Models</Label>
<p className="text-sm text-muted-foreground">
Resolved from the runtime configuration.
</p>
<div className="space-y-3 text-sm">
<div>
<div className="text-xs text-muted-foreground">Default model</div>
<div className="break-all font-medium">
{opencodeRuntimeConfigSummary?.model ?? 'No configured model'}
</div>
<Button variant="outline" size="sm" onClick={() => navigate('/models')}>
Configure models
</Button>
</div>
<div className="space-y-3 text-sm">
<div>
<div className="text-xs text-muted-foreground">Default model</div>
<div className="break-all font-medium">
{opencodeRuntimeConfigSummary?.model ?? 'No configured model'}
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Small model</div>
<div className="break-all font-medium">
{opencodeRuntimeConfigSummary?.smallModel ?? 'No configured small model'}
</div>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Providers</span>
<span className="font-medium">
{`${opencodeRuntimeConfigSummary?.providerCount ?? 0} providers`}
</span>
<div>
<div className="text-xs text-muted-foreground">Small model</div>
<div className="break-all font-medium">
{opencodeRuntimeConfigSummary?.smallModel ?? 'No configured small model'}
</div>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground">Providers</span>
<span className="font-medium">
{`${opencodeRuntimeConfigSummary?.providerCount ?? 0} providers`}
</span>
</div>
</div>
</div>
@@ -621,34 +695,25 @@ export function Settings() {
<Separator />
<section className="space-y-5">
<div>
<h2 className="text-lg font-medium">{t('updates.title')}</h2>
<p className="text-sm text-muted-foreground">{t('updates.description')}</p>
</div>
<div className="rounded-lg border bg-card p-4">
<UpdateSettings />
</div>
</section>
<Separator />
<section className="space-y-5">
<div>
<h2 className="text-lg font-medium">{t('advanced.title')}</h2>
<p className="text-sm text-muted-foreground">{t('advanced.description')}</p>
<section className="space-y-5" data-testid="settings-advanced-section">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-lg font-medium">{t('advanced.title')}</h2>
<p className="text-sm text-muted-foreground">{t('advanced.description')}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => void handleLockDevMode()}>
{t('admin.lock', '锁定')}
</Button>
</div>
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
<div>
<Label className="text-sm font-medium">{t('advanced.devMode')}</Label>
<p className="text-sm text-muted-foreground">{t('advanced.devModeDesc')}</p>
<p className="text-sm text-muted-foreground">
{t('advanced.sessionUnlocked', '管理员权限已在本次应用运行期间解锁。')}
</p>
</div>
<Switch
data-testid="settings-dev-mode-switch"
checked={devModeUnlocked}
onCheckedChange={setDevModeUnlocked}
/>
<Badge variant="secondary">{t('admin.unlocked', '已解锁')}</Badge>
</div>
<div className="flex items-center justify-between rounded-lg border bg-card p-4">
@@ -679,48 +744,47 @@ export function Settings() {
</div>
</div>
<DisclosureContent open={showLogs} className="mt-4" innerClassName="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs">
{logContent || '(No logs available yet)'}
{logContent || '(No logs available yet)'}
</DisclosureContent>
</div>
{devModeUnlocked && (
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">Telemetry Viewer</Label>
<p className="text-sm text-muted-foreground">
Local-only UI and performance events.
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowTelemetryViewer((value) => !value)}
>
{showTelemetryViewer ? 'Hide' : 'Show'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
clearUiTelemetry();
setTelemetryEntries([]);
}}
>
Clear
</Button>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<div>
<Label className="text-sm font-medium">Telemetry Viewer</Label>
<p className="text-sm text-muted-foreground">
Local-only UI and performance events.
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setShowTelemetryViewer((value) => !value)}
>
{showTelemetryViewer ? 'Hide' : 'Show'}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
clearUiTelemetry();
setTelemetryEntries([]);
}}
>
Clear
</Button>
</div>
<DisclosureContent open={showTelemetryViewer} className="mt-4" innerClassName="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs">
{telemetryEntries.length > 0
? JSON.stringify(telemetryEntries, null, 2)
: '(No telemetry entries)'}
</DisclosureContent>
</div>
)}
<DisclosureContent open={showTelemetryViewer} className="mt-4" innerClassName="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-muted p-3 text-xs">
{telemetryEntries.length > 0
? JSON.stringify(telemetryEntries, null, 2)
: '(No telemetry entries)'}
</DisclosureContent>
</div>
</section>
</DisclosureContent>
</DisclosureContent>
)}
</div>
</main>
);