mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-13 05:37:29 +08:00
Unify webui dialogs and loading flow
This commit is contained in:
@@ -8,16 +8,27 @@ type DialogOptions = {
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
initialValue?: string;
|
||||
inputLabel?: string;
|
||||
inputPlaceholder?: string;
|
||||
};
|
||||
|
||||
export const GlobalDialog: React.FC<{
|
||||
open: boolean;
|
||||
kind: 'notice' | 'confirm';
|
||||
kind: 'notice' | 'confirm' | 'prompt';
|
||||
options: DialogOptions;
|
||||
onConfirm: () => void;
|
||||
onConfirm: (value?: string) => void;
|
||||
onCancel: () => void;
|
||||
}> = ({ open, kind, options, onConfirm, onCancel }) => {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = React.useState(options.initialValue || '');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setValue(options.initialValue || '');
|
||||
}
|
||||
}, [open, options.initialValue]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
@@ -26,14 +37,33 @@ export const GlobalDialog: React.FC<{
|
||||
<motion.div className="w-full max-w-md rounded-2xl border border-zinc-700 bg-zinc-900 shadow-2xl"
|
||||
initial={{ scale: 0.95, y: 8 }} animate={{ scale: 1, y: 0 }} exit={{ scale: 0.95, y: 8 }}>
|
||||
<div className="px-5 py-4 border-b border-zinc-800">
|
||||
<h3 className="text-sm font-semibold text-zinc-100">{options.title || (kind === 'confirm' ? t('dialogPleaseConfirm') : t('dialogNotice'))}</h3>
|
||||
<h3 className="text-sm font-semibold text-zinc-100">{options.title || (kind === 'confirm' ? t('dialogPleaseConfirm') : kind === 'prompt' ? t('dialogInputTitle') : t('dialogNotice'))}</h3>
|
||||
</div>
|
||||
<div className="px-5 py-4 space-y-3">
|
||||
<div className="text-sm text-zinc-300 whitespace-pre-wrap">{options.message}</div>
|
||||
{kind === 'prompt' && (
|
||||
<div className="space-y-2">
|
||||
{options.inputLabel && <label className="text-xs text-zinc-400">{options.inputLabel}</label>}
|
||||
<input
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
onConfirm(value);
|
||||
}
|
||||
}}
|
||||
placeholder={options.inputPlaceholder || t('dialogInputPlaceholder')}
|
||||
className="w-full px-3 py-2 rounded-lg bg-zinc-950 border border-zinc-800 text-sm text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-5 py-4 text-sm text-zinc-300 whitespace-pre-wrap">{options.message}</div>
|
||||
<div className="px-5 pb-5 flex items-center justify-end gap-2">
|
||||
{kind === 'confirm' && (
|
||||
{(kind === 'confirm' || kind === 'prompt') && (
|
||||
<button onClick={onCancel} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-sm">{options.cancelText || t('cancel')}</button>
|
||||
)}
|
||||
<button onClick={onConfirm} className={`px-3 py-1.5 rounded-lg text-sm ${options.danger ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-indigo-600 hover:bg-indigo-500 text-white'}`}>
|
||||
<button onClick={() => onConfirm(kind === 'prompt' ? value : undefined)} className={`px-3 py-1.5 rounded-lg text-sm ${options.danger ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-indigo-600 hover:bg-indigo-500 text-white'}`}>
|
||||
{options.confirmText || t('dialogOk')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,10 @@ type UIContextType = {
|
||||
loading: boolean;
|
||||
showLoading: (text?: string) => void;
|
||||
hideLoading: () => void;
|
||||
withLoading: <T>(task: Promise<T> | (() => Promise<T>), text?: string) => Promise<T>;
|
||||
notify: (opts: DialogOptions | string) => Promise<void>;
|
||||
confirmDialog: (opts: DialogOptions | string) => Promise<boolean>;
|
||||
promptDialog: (opts: DialogOptions | string) => Promise<string | null>;
|
||||
openModal: (node: React.ReactNode, title?: string) => void;
|
||||
closeModal: () => void;
|
||||
};
|
||||
@@ -17,18 +19,28 @@ const UIContext = createContext<UIContextType | undefined>(undefined);
|
||||
|
||||
export const UIProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingCount, setLoadingCount] = useState(0);
|
||||
const [loadingText, setLoadingText] = useState(t('loading'));
|
||||
const [dialog, setDialog] = useState<null | { kind: 'notice' | 'confirm'; options: DialogOptions; resolve: (v: any) => void }>(null);
|
||||
const [dialog, setDialog] = useState<null | { kind: 'notice' | 'confirm' | 'prompt'; options: DialogOptions; resolve: (v: any) => void }>(null);
|
||||
const [customModal, setCustomModal] = useState<null | { title?: string; node: React.ReactNode }>(null);
|
||||
const loading = loadingCount > 0;
|
||||
|
||||
const value = useMemo<UIContextType>(() => ({
|
||||
loading,
|
||||
showLoading: (text?: string) => {
|
||||
setLoadingText(text || t('loading'));
|
||||
setLoading(true);
|
||||
setLoadingCount((count) => count + 1);
|
||||
},
|
||||
hideLoading: () => setLoadingCount((count) => Math.max(0, count - 1)),
|
||||
withLoading: async (task, text) => {
|
||||
setLoadingText(text || t('loading'));
|
||||
setLoadingCount((count) => count + 1);
|
||||
try {
|
||||
return typeof task === 'function' ? await task() : await task;
|
||||
} finally {
|
||||
setLoadingCount((count) => Math.max(0, count - 1));
|
||||
}
|
||||
},
|
||||
hideLoading: () => setLoading(false),
|
||||
notify: (opts) => new Promise<void>((resolve) => {
|
||||
const options = typeof opts === 'string' ? { message: opts } : opts;
|
||||
setDialog({ kind: 'notice', options, resolve });
|
||||
@@ -37,13 +49,23 @@ export const UIProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
const options = typeof opts === 'string' ? { message: opts } : opts;
|
||||
setDialog({ kind: 'confirm', options, resolve });
|
||||
}),
|
||||
promptDialog: (opts) => new Promise<string | null>((resolve) => {
|
||||
const options = typeof opts === 'string' ? { message: opts } : opts;
|
||||
setDialog({ kind: 'prompt', options, resolve });
|
||||
}),
|
||||
openModal: (node, title) => setCustomModal({ node, title }),
|
||||
closeModal: () => setCustomModal(null),
|
||||
}), [loading, t]);
|
||||
|
||||
const closeDialog = (result: boolean) => {
|
||||
const closeDialog = (result?: boolean | string | null) => {
|
||||
if (!dialog) return;
|
||||
dialog.resolve(dialog.kind === 'notice' ? undefined : result);
|
||||
if (dialog.kind === 'notice') {
|
||||
dialog.resolve(undefined);
|
||||
} else if (dialog.kind === 'prompt') {
|
||||
dialog.resolve(typeof result === 'string' ? result : null);
|
||||
} else {
|
||||
dialog.resolve(Boolean(result));
|
||||
}
|
||||
setDialog(null);
|
||||
};
|
||||
|
||||
@@ -65,10 +87,10 @@ export const UIProvider: React.FC<{ children: React.ReactNode }> = ({ children }
|
||||
|
||||
<GlobalDialog
|
||||
open={!!dialog}
|
||||
kind={(dialog?.kind || 'notice') as 'notice' | 'confirm'}
|
||||
kind={(dialog?.kind || 'notice') as 'notice' | 'confirm' | 'prompt'}
|
||||
options={dialog?.options || { message: '' }}
|
||||
onConfirm={() => closeDialog(true)}
|
||||
onCancel={() => closeDialog(false)}
|
||||
onConfirm={(value) => closeDialog(dialog?.kind === 'prompt' ? value || '' : true)}
|
||||
onCancel={() => closeDialog(dialog?.kind === 'prompt' ? null : false)}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -187,9 +187,17 @@ const resources = {
|
||||
modal: 'Modal',
|
||||
dialogPleaseConfirm: 'Please confirm',
|
||||
dialogNotice: 'Notice',
|
||||
dialogInputTitle: 'Input Required',
|
||||
dialogInputPlaceholder: 'Enter a value...',
|
||||
dialogOk: 'OK',
|
||||
requestFailed: 'Request Failed',
|
||||
saved: 'Saved',
|
||||
saving: 'Saving...',
|
||||
creating: 'Creating...',
|
||||
deleting: 'Deleting...',
|
||||
memoryCreateTitle: 'Create Memory File',
|
||||
memoryFileSaved: 'Memory file saved.',
|
||||
cronSaved: 'Cron job saved.',
|
||||
reloadHistory: 'Reload History',
|
||||
user: 'User',
|
||||
exec: 'Exec',
|
||||
@@ -236,6 +244,9 @@ const resources = {
|
||||
configDiffPreview: 'Diff Preview',
|
||||
configBasicMode: 'Basic Mode',
|
||||
configAdvancedMode: 'Advanced Mode',
|
||||
configSaved: 'Config saved successfully.',
|
||||
configRiskyChangeConfirmTitle: 'Confirm Risky Config Change',
|
||||
configRiskyChangeConfirmMessage: 'These sensitive fields changed: {{fields}}. Save anyway?',
|
||||
configHotOnly: 'Hot-reload fields only',
|
||||
configSearchPlaceholder: 'Search group...',
|
||||
configHotFieldsFull: 'Hot-reload fields (full)',
|
||||
@@ -621,9 +632,17 @@ const resources = {
|
||||
modal: '弹窗',
|
||||
dialogPleaseConfirm: '请确认',
|
||||
dialogNotice: '提示',
|
||||
dialogInputTitle: '请输入',
|
||||
dialogInputPlaceholder: '请输入内容...',
|
||||
dialogOk: '确定',
|
||||
requestFailed: '请求失败',
|
||||
saved: '已保存',
|
||||
saving: '保存中...',
|
||||
creating: '创建中...',
|
||||
deleting: '删除中...',
|
||||
memoryCreateTitle: '创建记忆文件',
|
||||
memoryFileSaved: '记忆文件已保存。',
|
||||
cronSaved: '定时任务已保存。',
|
||||
reloadHistory: '重新加载历史',
|
||||
user: '用户',
|
||||
exec: '执行',
|
||||
@@ -670,6 +689,9 @@ const resources = {
|
||||
configDiffPreview: '差异预览',
|
||||
configBasicMode: '基础模式',
|
||||
configAdvancedMode: '高级模式',
|
||||
configSaved: '配置已保存。',
|
||||
configRiskyChangeConfirmTitle: '确认高风险配置变更',
|
||||
configRiskyChangeConfirmMessage: '以下敏感字段已变更:{{fields}}。仍要保存吗?',
|
||||
configHotOnly: '仅热更新字段',
|
||||
configSearchPlaceholder: '搜索分类...',
|
||||
configHotFieldsFull: '热更新字段(完整)',
|
||||
|
||||
@@ -148,14 +148,47 @@ const Config: React.FC = () => {
|
||||
async function saveConfig() {
|
||||
try {
|
||||
const payload = showRaw ? JSON.parse(cfgRaw) : cfg;
|
||||
const r = await fetch(`/webui/api/config${q}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||||
});
|
||||
alert(await r.text());
|
||||
const submit = async (confirmRisky: boolean) => {
|
||||
const body = confirmRisky ? { ...payload, confirm_risky: true } : payload;
|
||||
return ui.withLoading(async () => {
|
||||
const r = await fetch(`/webui/api/config${q}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
let data: any = null;
|
||||
try {
|
||||
data = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
return { ok: r.ok, text, data };
|
||||
}, t('saving'));
|
||||
};
|
||||
|
||||
let result = await submit(false);
|
||||
if (!result.ok && result.data?.requires_confirm) {
|
||||
const changedFields = Array.isArray(result.data?.changed_fields) ? result.data.changed_fields.join(', ') : '';
|
||||
const ok = await ui.confirmDialog({
|
||||
title: t('configRiskyChangeConfirmTitle'),
|
||||
message: t('configRiskyChangeConfirmMessage', { fields: changedFields || '-' }),
|
||||
danger: true,
|
||||
confirmText: t('saveChanges'),
|
||||
});
|
||||
if (!ok) return;
|
||||
result = await submit(true);
|
||||
}
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(result.data?.error || result.text || 'save failed');
|
||||
}
|
||||
|
||||
await ui.notify({ title: t('saved'), message: t('configSaved') });
|
||||
setBaseline(JSON.parse(JSON.stringify(payload)));
|
||||
setShowDiff(false);
|
||||
} catch (e) {
|
||||
alert(`${t('saveConfigFailed')}: ${e}`);
|
||||
await ui.notify({ title: t('requestFailed'), message: `${t('saveConfigFailed')}: ${e}` });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -93,14 +93,19 @@ const Cron: React.FC = () => {
|
||||
if (!ok) return;
|
||||
}
|
||||
try {
|
||||
await fetch(`/webui/api/cron${q}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, id }),
|
||||
});
|
||||
await ui.withLoading(async () => {
|
||||
const r = await fetch(`/webui/api/cron${q}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action, id }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(await r.text());
|
||||
}
|
||||
}, t('loading'));
|
||||
await refreshCron();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
await ui.notify({ title: t('actionFailed'), message: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,12 +157,13 @@ const Cron: React.FC = () => {
|
||||
if (r.ok) {
|
||||
setIsCronModalOpen(false);
|
||||
await refreshCron();
|
||||
await ui.notify({ title: t('saved'), message: t('cronSaved') });
|
||||
} else {
|
||||
const err = await r.text();
|
||||
alert(`${t('actionFailed')}: ${err}`);
|
||||
await ui.notify({ title: t('actionFailed'), message: err });
|
||||
}
|
||||
} catch (e) {
|
||||
alert(`${t('actionFailed')}: ${e}`);
|
||||
await ui.notify({ title: t('actionFailed'), message: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ const Memory: React.FC = () => {
|
||||
|
||||
async function loadFiles() {
|
||||
const r = await fetch(`/webui/api/memory${q}`);
|
||||
if (!r.ok) {
|
||||
await ui.notify({ title: t('requestFailed'), message: await r.text() });
|
||||
return;
|
||||
}
|
||||
const j = await r.json();
|
||||
setFiles(Array.isArray(j.files) ? j.files : []);
|
||||
}
|
||||
@@ -21,6 +25,10 @@ const Memory: React.FC = () => {
|
||||
|
||||
async function openFile(path: string) {
|
||||
const r = await fetch(`/webui/api/memory${qp('path', path)}`);
|
||||
if (!r.ok) {
|
||||
await ui.notify({ title: t('requestFailed'), message: await r.text() });
|
||||
return;
|
||||
}
|
||||
const j = await r.json();
|
||||
setActive(path);
|
||||
setContent(j.content || '');
|
||||
@@ -28,12 +36,22 @@ const Memory: React.FC = () => {
|
||||
|
||||
async function saveFile() {
|
||||
if (!active) return;
|
||||
await fetch(`/webui/api/memory${q}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: active, content }),
|
||||
});
|
||||
await loadFiles();
|
||||
try {
|
||||
await ui.withLoading(async () => {
|
||||
const r = await fetch(`/webui/api/memory${q}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: active, content }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(await r.text());
|
||||
}
|
||||
await loadFiles();
|
||||
}, t('saving'));
|
||||
await ui.notify({ title: t('saved'), message: t('memoryFileSaved') });
|
||||
} catch (e) {
|
||||
await ui.notify({ title: t('requestFailed'), message: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFile(path: string) {
|
||||
@@ -44,24 +62,48 @@ const Memory: React.FC = () => {
|
||||
confirmText: t('delete'),
|
||||
});
|
||||
if (!ok) return;
|
||||
await fetch(`/webui/api/memory${qp('path', path)}`, { method: 'DELETE' });
|
||||
if (active === path) {
|
||||
setActive('');
|
||||
setContent('');
|
||||
try {
|
||||
await ui.withLoading(async () => {
|
||||
const r = await fetch(`/webui/api/memory${qp('path', path)}`, { method: 'DELETE' });
|
||||
if (!r.ok) {
|
||||
throw new Error(await r.text());
|
||||
}
|
||||
if (active === path) {
|
||||
setActive('');
|
||||
setContent('');
|
||||
}
|
||||
await loadFiles();
|
||||
}, t('deleting'));
|
||||
} catch (e) {
|
||||
await ui.notify({ title: t('requestFailed'), message: String(e) });
|
||||
}
|
||||
await loadFiles();
|
||||
}
|
||||
|
||||
async function createFile() {
|
||||
const name = prompt(t('memoryFileNamePrompt'), `note-${Date.now()}.md`);
|
||||
if (!name) return;
|
||||
await fetch(`/webui/api/memory${q}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: name, content: '' }),
|
||||
const name = await ui.promptDialog({
|
||||
title: t('memoryCreateTitle'),
|
||||
message: t('memoryFileNamePrompt'),
|
||||
confirmText: t('create'),
|
||||
initialValue: `note-${Date.now()}.md`,
|
||||
inputPlaceholder: 'note.md',
|
||||
});
|
||||
await loadFiles();
|
||||
await openFile(name);
|
||||
if (!name) return;
|
||||
try {
|
||||
await ui.withLoading(async () => {
|
||||
const r = await fetch(`/webui/api/memory${q}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: name, content: '' }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
throw new Error(await r.text());
|
||||
}
|
||||
await loadFiles();
|
||||
await openFile(name);
|
||||
}, t('creating'));
|
||||
} catch (e) {
|
||||
await ui.notify({ title: t('requestFailed'), message: String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user