diff --git a/webui/src/components/GlobalDialog.tsx b/webui/src/components/GlobalDialog.tsx index 768ddb9..137fd01 100644 --- a/webui/src/components/GlobalDialog.tsx +++ b/webui/src/components/GlobalDialog.tsx @@ -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 ( {open && ( @@ -26,14 +37,33 @@ export const GlobalDialog: React.FC<{
-

{options.title || (kind === 'confirm' ? t('dialogPleaseConfirm') : t('dialogNotice'))}

+

{options.title || (kind === 'confirm' ? t('dialogPleaseConfirm') : kind === 'prompt' ? t('dialogInputTitle') : t('dialogNotice'))}

+
+
+
{options.message}
+ {kind === 'prompt' && ( +
+ {options.inputLabel && } + 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" + /> +
+ )}
-
{options.message}
- {kind === 'confirm' && ( + {(kind === 'confirm' || kind === 'prompt') && ( )} -
diff --git a/webui/src/context/UIContext.tsx b/webui/src/context/UIContext.tsx index d0b6f6c..77d5a5a 100644 --- a/webui/src/context/UIContext.tsx +++ b/webui/src/context/UIContext.tsx @@ -7,8 +7,10 @@ type UIContextType = { loading: boolean; showLoading: (text?: string) => void; hideLoading: () => void; + withLoading: (task: Promise | (() => Promise), text?: string) => Promise; notify: (opts: DialogOptions | string) => Promise; confirmDialog: (opts: DialogOptions | string) => Promise; + promptDialog: (opts: DialogOptions | string) => Promise; openModal: (node: React.ReactNode, title?: string) => void; closeModal: () => void; }; @@ -17,18 +19,28 @@ const UIContext = createContext(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 void }>(null); + const [dialog, setDialog] = useState void }>(null); const [customModal, setCustomModal] = useState(null); + const loading = loadingCount > 0; const value = useMemo(() => ({ 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((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((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 } closeDialog(true)} - onCancel={() => closeDialog(false)} + onConfirm={(value) => closeDialog(dialog?.kind === 'prompt' ? value || '' : true)} + onCancel={() => closeDialog(dialog?.kind === 'prompt' ? null : false)} /> diff --git a/webui/src/i18n/index.ts b/webui/src/i18n/index.ts index dea9dc5..7c9929d 100644 --- a/webui/src/i18n/index.ts +++ b/webui/src/i18n/index.ts @@ -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: '热更新字段(完整)', diff --git a/webui/src/pages/Config.tsx b/webui/src/pages/Config.tsx index ab29374..b378403 100644 --- a/webui/src/pages/Config.tsx +++ b/webui/src/pages/Config.tsx @@ -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}` }); } } diff --git a/webui/src/pages/Cron.tsx b/webui/src/pages/Cron.tsx index 8c3dfd2..82bbde2 100644 --- a/webui/src/pages/Cron.tsx +++ b/webui/src/pages/Cron.tsx @@ -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) }); } } diff --git a/webui/src/pages/Memory.tsx b/webui/src/pages/Memory.tsx index 61d4002..65d08e1 100644 --- a/webui/src/pages/Memory.tsx +++ b/webui/src/pages/Memory.tsx @@ -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(() => {