fix ui and oauth

This commit is contained in:
LPF
2026-03-12 00:32:56 +08:00
parent 5e0c371bb9
commit e2cea0bce2
19 changed files with 674 additions and 188 deletions

View File

@@ -4,7 +4,7 @@ import { RefreshCw, Save } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { FixedButton } from '../components/Button';
import { Button, FixedButton } from '../components/Button';
import ChannelSectionCard from '../components/channel/ChannelSectionCard';
import ChannelFieldRenderer from '../components/channel/ChannelFieldRenderer';
import {
@@ -69,6 +69,15 @@ const ChannelSettings: React.FC = () => {
const [draft, setDraft] = useState<Record<string, any>>({});
const [saving, setSaving] = useState(false);
const [waStatus, setWaStatus] = useState<WhatsAppStatusPayload | null>(null);
const draftRef = React.useRef<Record<string, any>>({});
const updateDraft: React.Dispatch<React.SetStateAction<Record<string, any>>> = React.useCallback((next) => {
setDraft((prev) => {
const resolved = typeof next === 'function' ? (next as (value: Record<string, any>) => Record<string, any>)(prev) : next;
draftRef.current = resolved;
return resolved;
});
}, []);
useEffect(() => {
if (!fallbackChannel) {
@@ -80,6 +89,7 @@ const ChannelSettings: React.FC = () => {
return;
}
const next = cloneJSON(((cfg as any)?.channels?.[definition.id] || {}) as Record<string, any>);
draftRef.current = next;
setDraft(next);
}, [availableChannelKeys, cfg, definition, fallbackChannel, key, navigate]);
@@ -112,13 +122,17 @@ const ChannelSettings: React.FC = () => {
if (!definition || !availableChannelKeys.includes(key)) return null;
const saveChannel = async () => {
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
setSaving(true);
try {
const nextCfg = cloneJSON(cfg || {});
if (!nextCfg.channels || typeof nextCfg.channels !== 'object') {
(nextCfg as any).channels = {};
}
(nextCfg as any).channels[definition.id] = cloneJSON(draft);
(nextCfg as any).channels[definition.id] = cloneJSON(draftRef.current || {});
const submit = async (confirmRisky: boolean) => {
const body = confirmRisky ? { ...nextCfg, confirm_risky: true } : nextCfg;
return ui.withLoading(async () => {
@@ -202,9 +216,10 @@ const ChannelSettings: React.FC = () => {
<RefreshCw className="h-4 w-4" />
</FixedButton>
)}
<FixedButton onClick={saveChannel} disabled={saving} variant="primary" label={saving ? t('loading') : t('saveChanges')}>
<Button onClick={saveChannel} disabled={saving} variant="primary" size="sm" radius="lg" gap="1">
<Save className="h-4 w-4" />
</FixedButton>
{saving ? t('loading') : t('saveChanges')}
</Button>
</>
}
/>
@@ -229,7 +244,7 @@ const ChannelSettings: React.FC = () => {
field={field}
getDescription={getChannelFieldDescription}
parseList={parseChannelList}
setDraft={setDraft}
setDraft={updateDraft}
t={t}
/>
))}

View File

@@ -6,7 +6,7 @@ import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import EmptyState from '../components/EmptyState';
import { CheckboxField, FieldBlock, SelectField, TextField, TextareaField } from '../components/FormControls';
import { CheckboxCardField, FieldBlock, SelectField, TextField, TextareaField } from '../components/FormControls';
import { ModalBackdrop, ModalBody, ModalCard, ModalFooter, ModalHeader, ModalShell } from '../components/ModalFrame';
import PageHeader from '../components/PageHeader';
import { CronJob } from '../types';
@@ -343,24 +343,20 @@ const Cron: React.FC = () => {
</FieldBlock>
</div>
<div className="flex items-center gap-6 pt-2">
<label className="flex items-center gap-3 cursor-pointer group">
<CheckboxField
<div className="grid grid-cols-1 gap-3 pt-2 md:grid-cols-2">
<CheckboxCardField
checked={cronForm.deliver}
onChange={(e) => setCronForm({ ...cronForm, deliver: e.target.checked })}
className="w-4 h-4 rounded border-zinc-700 text-indigo-500 focus:ring-indigo-500 focus:ring-offset-zinc-900 bg-zinc-950"
help={t('cronDeliverHint', { defaultValue: 'Send the message through the selected channel.' })}
label={t('deliver')}
onChange={(checked) => setCronForm({ ...cronForm, deliver: checked })}
/>
<span className="text-sm font-medium text-zinc-400 group-hover:text-zinc-200 transition-colors">{t('deliver')}</span>
</label>
<label className="flex items-center gap-3 cursor-pointer group">
<CheckboxField
<CheckboxCardField
checked={cronForm.enabled}
onChange={(e) => setCronForm({ ...cronForm, enabled: e.target.checked })}
className="w-4 h-4 rounded border-zinc-700 text-indigo-500 focus:ring-indigo-500 focus:ring-offset-zinc-900 bg-zinc-950"
help={t('cronEnabledHint', { defaultValue: 'Enable this cron job immediately after saving.' })}
label={t('active')}
onChange={(checked) => setCronForm({ ...cronForm, enabled: checked })}
/>
<span className="text-sm font-medium text-zinc-400 group-hover:text-zinc-200 transition-colors">{t('active')}</span>
</label>
</div>
</div>
</ModalBody>
<ModalFooter className="bg-zinc-900/20">

View File

@@ -456,10 +456,11 @@ const MCP: React.FC = () => {
</FixedButton>
)}
<Button onClick={closeModal} size="sm">{t('cancel')}</Button>
<FixedButton onClick={saveServer} variant="primary" label={t('saveChanges')}>
<Save className="w-4 h-4" />
</FixedButton>
</div>
<Button onClick={saveServer} variant="primary" size="sm" radius="lg" gap="1">
<Save className="w-4 h-4" />
{t('saveChanges')}
</Button>
</div>
</ModalFooter>
</ModalCard>
</motion.div>

View File

@@ -3,7 +3,7 @@ import { Save, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { FixedButton } from '../components/Button';
import { Button, FixedButton } from '../components/Button';
import { TextareaField } from '../components/FormControls';
import FileListItem from '../components/FileListItem';
@@ -153,10 +153,11 @@ const Memory: React.FC = () => {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="ui-text-primary font-semibold">{active || t('noFileSelected')}</h2>
<FixedButton onClick={saveFile} variant="primary" radius="xl" label={t('save')}>
<Save className="w-4 h-4" />
</FixedButton>
</div>
<Button onClick={saveFile} variant="primary" size="sm" radius="xl" gap="1">
<Save className="w-4 h-4" />
{t('save')}
</Button>
</div>
<TextareaField value={content} onChange={(e) => setContent(e.target.value)} className="w-full h-[50vh] lg:h-[80vh] rounded-[24px] p-4" />
</div>
</main>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { RefreshCw, Save } from 'lucide-react';
import { useAppContext } from '../context/AppContext';
@@ -29,6 +29,8 @@ const Providers: React.FC = () => {
const latestProviderRuntimeRef = useRef<any[]>([]);
const [displayedProviderRuntimeItems, setDisplayedProviderRuntimeItems] = useState<any[]>([]);
const [oauthAccounts, setOAuthAccounts] = useState<Record<string, Array<any>>>({});
const [oauthAccountsLoading, setOAuthAccountsLoading] = useState<Record<string, boolean>>({});
const [oauthAccountsLoaded, setOAuthAccountsLoaded] = useState<Record<string, boolean>>({});
const providerEntries = useMemo(() => {
const providers = (((cfg as any)?.models || {}) as any)?.providers || {};
@@ -77,14 +79,6 @@ const Providers: React.FC = () => {
}
}, [activeProviderName, providerEntries]);
useEffect(() => {
providerEntries.forEach(([name, p]) => {
if (['oauth', 'hybrid'].includes(String(p?.auth || ''))) {
loadOAuthAccounts(name);
}
});
}, [providerEntries]);
useEffect(() => {
if (baseline == null && cfg && Object.keys(cfg).length > 0) {
setBaseline(cloneJSON(cfg));
@@ -131,6 +125,49 @@ const Providers: React.FC = () => {
ui,
});
const loadOAuthAccountsNow = useCallback(async (name: string) => {
if (!name) return;
setOAuthAccountsLoading((prev) => ({ ...prev, [name]: true }));
try {
await loadOAuthAccounts(name);
setOAuthAccountsLoaded((prev) => ({ ...prev, [name]: true }));
} finally {
setOAuthAccountsLoading((prev) => ({ ...prev, [name]: false }));
}
}, [loadOAuthAccounts]);
useEffect(() => {
providerEntries.forEach(([name, p]) => {
if (!['oauth', 'hybrid'].includes(String(p?.auth || ''))) return;
if (oauthAccountsLoaded[name] || oauthAccountsLoading[name]) return;
void loadOAuthAccountsNow(name);
});
}, [loadOAuthAccountsNow, oauthAccountsLoaded, oauthAccountsLoading, providerEntries]);
useEffect(() => {
if (!activeProviderEntry) return;
const [name, provider] = activeProviderEntry;
if (!['oauth', 'hybrid'].includes(String(provider?.auth || ''))) return;
if (oauthAccountsLoaded[name] || oauthAccountsLoading[name]) return;
void loadOAuthAccountsNow(name);
}, [activeProviderEntry, loadOAuthAccountsNow, oauthAccountsLoaded, oauthAccountsLoading]);
useEffect(() => {
const oauthProviderNames = new Set(
providerEntries
.filter(([, provider]) => ['oauth', 'hybrid'].includes(String(provider?.auth || '')))
.map(([name]) => name),
);
setOAuthAccountsLoaded((prev) => {
const next = Object.fromEntries(Object.entries(prev).filter(([name]) => oauthProviderNames.has(name)));
return Object.keys(next).length === Object.keys(prev).length ? prev : next;
});
setOAuthAccountsLoading((prev) => {
const next = Object.fromEntries(Object.entries(prev).filter(([name]) => oauthProviderNames.has(name)));
return Object.keys(next).length === Object.keys(prev).length ? prev : next;
});
}, [providerEntries]);
const { saveConfig } = useConfigSaveAction({
cfg,
cfgRaw,
@@ -161,12 +198,13 @@ const Providers: React.FC = () => {
<RefreshCw className="w-4 h-4" />
</FixedButton>
<Button onClick={() => setShowDiff(true)} size="sm">{t('configDiffPreview')}</Button>
<FixedButton onClick={saveConfig} variant="primary" label={t('saveChanges')}>
<Save className="w-4 h-4" />
</FixedButton>
</div>
}
/>
<Button onClick={saveConfig} variant="primary" size="sm" radius="lg" gap="1">
<Save className="w-4 h-4" />
{t('saveChanges')}
</Button>
</div>
}
/>
<div className="brand-card ui-border-subtle border rounded-[30px] p-4 md:p-6 space-y-4">
<ProviderRuntimeToolbar
@@ -217,10 +255,11 @@ const Providers: React.FC = () => {
key={activeProviderEntry[0]}
name={activeProviderEntry[0]}
oauthAccounts={oauthAccounts[activeProviderEntry[0]] || []}
oauthAccountsLoading={!!oauthAccountsLoading[activeProviderEntry[0]]}
onClearOAuthCooldown={(credentialFile) => clearOAuthCooldown(activeProviderEntry[0], credentialFile)}
onDeleteOAuthAccount={(credentialFile) => deleteOAuthAccount(activeProviderEntry[0], credentialFile)}
onFieldChange={(field, value) => updateProxyField(activeProviderEntry[0], field, value)}
onLoadOAuthAccounts={() => loadOAuthAccounts(activeProviderEntry[0])}
onLoadOAuthAccounts={() => loadOAuthAccountsNow(activeProviderEntry[0])}
onRefreshOAuthAccount={(credentialFile) => refreshOAuthAccount(activeProviderEntry[0], credentialFile)}
onRemove={() => removeProxy(activeProviderEntry[0])}
onStartOAuthLogin={() => startOAuthLogin(activeProviderEntry[0], activeProviderEntry[1])}

View File

@@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import { useUI } from '../context/UIContext';
import { Button, FixedButton } from '../components/Button';
import { CheckboxField, TextField, TextareaField } from '../components/FormControls';
import { TextField, TextareaField, ToolbarCheckboxField } from '../components/FormControls';
import { ModalBackdrop, ModalBody, ModalCard, ModalHeader, ModalShell } from '../components/ModalFrame';
import PageHeader from '../components/PageHeader';
import ToolbarRow from '../components/ToolbarRow';
@@ -231,14 +231,13 @@ const Skills: React.FC = () => {
>
{installingSkill ? t('loading') : t('install')}
</Button>
<label className="flex shrink-0 items-center gap-2 whitespace-nowrap text-xs text-zinc-400">
<CheckboxField
checked={ignoreSuspicious}
disabled={installingSkill}
onChange={(e) => setIgnoreSuspicious(e.target.checked)}
/>
{t('skillsIgnoreSuspicious')}
</label>
<ToolbarCheckboxField
checked={ignoreSuspicious}
className={installingSkill ? 'pointer-events-none opacity-60' : 'shrink-0'}
help={t('skillsIgnoreSuspiciousHint', { defaultValue: 'Use --force to ignore suspicious package warnings.' })}
label={t('skillsIgnoreSuspicious')}
onChange={setIgnoreSuspicious}
/>
</ToolbarRow>
{!clawhubInstalled && (
@@ -319,9 +318,10 @@ const Skills: React.FC = () => {
className="px-4 py-3"
actions={
<>
<FixedButton onClick={saveFile} variant="success" radius="lg" label={t('save')}>
<Button onClick={saveFile} variant="success" size="sm" radius="lg" gap="1">
<Save className="w-4 h-4" />
</FixedButton>
{t('save')}
</Button>
<FixedButton onClick={() => setIsFileModalOpen(false)} radius="full" label={t('close')}>
<X className="w-4 h-4" />
</FixedButton>