Unify webui dialogs and loading flow

This commit is contained in:
lpf
2026-03-06 14:21:47 +08:00
parent 5e421bb730
commit 86691f75d0
6 changed files with 202 additions and 47 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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: '热更新字段(完整)',

View File

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

View File

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

View File

@@ -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(() => {