dashboard-nanobot/frontend/src/modules/platform/components/PlatformSettingsPage.tsx

384 lines
17 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { ChevronLeft, ChevronRight, Pencil, Plus, RefreshCw, Search, Trash2, X } from 'lucide-react';
import { APP_ENDPOINTS } from '../../../config/env';
import { LucentSelect } from '../../../components/lucent/LucentSelect';
import type { PlatformSettings, SystemSettingItem } from '../types';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { useLucentPrompt } from '../../../components/lucent/LucentPromptProvider';
interface PlatformSettingsPageProps {
isZh: boolean;
}
interface SystemSettingsResponse {
items: SystemSettingItem[];
}
interface SettingDraft {
key: string;
name: string;
category: string;
description: string;
value_type: string;
value: string;
is_public: boolean;
sort_order: string;
}
const emptyDraft: SettingDraft = {
key: '',
name: '',
category: 'general',
description: '',
value_type: 'string',
value: '',
is_public: false,
sort_order: '100',
};
function toDraft(item?: SystemSettingItem): SettingDraft {
if (!item) return emptyDraft;
return {
key: item.key,
name: item.name,
category: item.category,
description: item.description,
value_type: item.value_type,
value: item.value_type === 'json' ? JSON.stringify(item.value, null, 2) : String(item.value ?? ''),
is_public: item.is_public,
sort_order: String(item.sort_order ?? 100),
};
}
function parseValue(draft: SettingDraft) {
if (draft.value_type === 'integer') return Number.parseInt(draft.value || '0', 10) || 0;
if (draft.value_type === 'float') return Number.parseFloat(draft.value || '0') || 0;
if (draft.value_type === 'boolean') return ['1', 'true', 'yes', 'on'].includes(draft.value.trim().toLowerCase());
if (draft.value_type === 'json') return JSON.parse(draft.value || 'null');
return draft.value;
}
function displayValue(item: SystemSettingItem) {
if (item.value_type === 'json') return JSON.stringify(item.value);
if (item.value_type === 'boolean') return String(Boolean(item.value));
return String(item.value ?? '');
}
function normalizePageSize(value: unknown, fallback = 10) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.max(1, Math.min(100, Math.floor(parsed)));
}
export function PlatformSettingsPage({ isZh }: PlatformSettingsPageProps) {
const { notify, confirm } = useLucentPrompt();
const [items, setItems] = useState<SystemSettingItem[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [search, setSearch] = useState('');
const [showEditor, setShowEditor] = useState(false);
const [editingKey, setEditingKey] = useState('');
const [draft, setDraft] = useState<SettingDraft>(emptyDraft);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const loadRows = async () => {
setLoading(true);
try {
const res = await axios.get<SystemSettingsResponse>(`${APP_ENDPOINTS.apiBase}/platform/system-settings`);
setItems(Array.isArray(res.data?.items) ? res.data.items : []);
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '读取系统参数失败。' : 'Failed to load system settings.'), { tone: 'error' });
} finally {
setLoading(false);
}
};
const refreshSnapshot = async () => {
try {
const res = await axios.get<PlatformSettings>(`${APP_ENDPOINTS.apiBase}/platform/settings`);
setPageSize(normalizePageSize(res.data?.page_size, 10));
} catch {
// ignore snapshot refresh failures
}
};
useEffect(() => {
setPage(1);
void (async () => {
await Promise.allSettled([loadRows(), refreshSnapshot()]);
})();
}, []);
useEffect(() => {
setPage(1);
}, [search]);
const filtered = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) =>
[item.key, item.name, item.category, item.description].some((value) => String(value || '').toLowerCase().includes(keyword)),
);
}, [items, search]);
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filtered.length / pageSize));
if (page > maxPage) setPage(maxPage);
}, [filtered.length, page, pageSize]);
const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize));
const pagedItems = useMemo(() => filtered.slice((page - 1) * pageSize, page * pageSize), [filtered, page, pageSize]);
return (
<div className="platform-page-stack">
<section className="panel stack">
<div className="page-section-head">
<div>
<h3>{isZh ? '系统参数' : 'System Settings'}</h3>
<p className="panel-desc">
{isZh ? '参数修改后会立即同步到运行时,无需重启平台。' : 'Setting changes are applied to the runtime immediately without restarting the platform.'}
</p>
</div>
</div>
<div className="platform-settings-toolbar">
<div className="ops-searchbar platform-searchbar platform-settings-search">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={isZh ? '搜索参数...' : 'Search settings...'}
aria-label={isZh ? '搜索参数' : 'Search settings'}
/>
<button
type="button"
className="ops-search-inline-btn"
onClick={() => setSearch('')}
title={search.trim() ? (isZh ? '清除搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
aria-label={search.trim() ? (isZh ? '清除搜索' : 'Clear search') : (isZh ? '搜索' : 'Search')}
>
{search.trim() ? <X size={14} /> : <Search size={14} />}
</button>
</div>
<div className="skill-market-admin-actions">
<button className="btn btn-secondary btn-sm" type="button" disabled={loading} onClick={() => void loadRows()}>
{loading ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
<span style={{ marginLeft: 6 }}>{isZh ? '刷新' : 'Refresh'}</span>
</button>
<button
className="btn btn-primary"
type="button"
onClick={() => {
setEditingKey('');
setDraft(emptyDraft);
setShowEditor(true);
}}
>
<Plus size={14} />
<span style={{ marginLeft: 6 }}>{isZh ? '新增参数' : 'Add Setting'}</span>
</button>
</div>
</div>
<div className="platform-settings-table-wrap">
<table className="table platform-settings-table">
<thead>
<tr>
<th>{isZh ? '参数键' : 'Key'}</th>
<th>{isZh ? '名称' : 'Name'}</th>
<th>{isZh ? '当前值' : 'Value'}</th>
<th>{isZh ? '类型' : 'Type'}</th>
<th>{isZh ? '分类' : 'Category'}</th>
<th>{isZh ? '描述' : 'Description'}</th>
<th>{isZh ? '操作' : 'Actions'}</th>
</tr>
</thead>
<tbody>
{pagedItems.map((item) => (
<tr key={item.key}>
<td>
<div className="mono">{item.key}</div>
{item.is_public ? <div className="platform-setting-public">{isZh ? '前端可访问' : 'Public'}</div> : null}
</td>
<td>{item.name}</td>
<td><span className="platform-setting-value">{displayValue(item)}</span></td>
<td>{item.value_type}</td>
<td>{item.category}</td>
<td>{item.description}</td>
<td>
<div className="platform-settings-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => {
setEditingKey(item.key);
setDraft(toDraft(item));
setShowEditor(true);
}}
tooltip={isZh ? '编辑' : 'Edit'}
aria-label={isZh ? '编辑' : 'Edit'}
>
<Pencil size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-danger btn-sm icon-btn"
onClick={() => {
void (async () => {
const ok = await confirm({
title: isZh ? '删除系统参数' : 'Delete System Setting',
message: `${isZh ? '确认删除' : 'Delete'} ${item.key}?`,
tone: 'warning',
});
if (!ok) return;
try {
await axios.delete(`${APP_ENDPOINTS.apiBase}/platform/system-settings/${encodeURIComponent(item.key)}`);
await loadRows();
await refreshSnapshot();
} catch (error: any) {
notify(error?.response?.data?.detail || (isZh ? '删除失败。' : 'Delete failed.'), { tone: 'error' });
}
})();
}}
tooltip={isZh ? '删除' : 'Delete'}
aria-label={isZh ? '删除' : 'Delete'}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
{filtered.length === 0 ? <div className="ops-empty-inline">{isZh ? '暂无系统参数。' : 'No system settings.'}</div> : null}
</div>
<div className="platform-settings-pager">
<span className="pager-status">{isZh ? `${page} / ${pageCount} 页,共 ${filtered.length}` : `Page ${page} / ${pageCount}, ${filtered.length} rows`}</span>
<div className="platform-usage-pager-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={loading || page <= 1}
onClick={() => setPage((value) => Math.max(1, value - 1))}
tooltip={isZh ? '上一页' : 'Previous'}
aria-label={isZh ? '上一页' : 'Previous'}
>
<ChevronLeft size={16} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={loading || page >= pageCount}
onClick={() => setPage((value) => Math.min(pageCount, value + 1))}
tooltip={isZh ? '下一页' : 'Next'}
aria-label={isZh ? '下一页' : 'Next'}
>
<ChevronRight size={16} />
</LucentIconButton>
</div>
</div>
</section>
{showEditor ? (
<div className="modal-mask" onClick={() => setShowEditor(false)}>
<div className="modal-card platform-setting-editor" onClick={(event) => event.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{editingKey ? (isZh ? '编辑参数' : 'Edit Setting') : (isZh ? '新增参数' : 'Create Setting')}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={() => setShowEditor(false)} tooltip={isZh ? '关闭' : 'Close'} aria-label={isZh ? '关闭' : 'Close'}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<label className="field-label">{isZh ? '参数键' : 'Key'}</label>
<input className="input" value={draft.key} onChange={(event) => setDraft((prev) => ({ ...prev, key: event.target.value }))} disabled={Boolean(editingKey)} />
<label className="field-label">{isZh ? '名称' : 'Name'}</label>
<input className="input" value={draft.name} onChange={(event) => setDraft((prev) => ({ ...prev, name: event.target.value }))} />
<label className="field-label">{isZh ? '类型' : 'Type'}</label>
<LucentSelect value={draft.value_type} onChange={(event) => setDraft((prev) => ({ ...prev, value_type: event.target.value }))}>
<option value="string">string</option>
<option value="integer">integer</option>
<option value="float">float</option>
<option value="boolean">boolean</option>
<option value="json">json</option>
</LucentSelect>
<label className="field-label">{isZh ? '当前值' : 'Value'}</label>
{draft.value_type === 'json' ? (
<textarea className="textarea" rows={8} value={draft.value} onChange={(event) => setDraft((prev) => ({ ...prev, value: event.target.value }))} />
) : (
<input className="input" value={draft.value} onChange={(event) => setDraft((prev) => ({ ...prev, value: event.target.value }))} />
)}
<label className="field-label">{isZh ? '分类' : 'Category'}</label>
<input className="input" value={draft.category} onChange={(event) => setDraft((prev) => ({ ...prev, category: event.target.value }))} />
<label className="field-label">{isZh ? '描述' : 'Description'}</label>
<textarea className="textarea" rows={4} value={draft.description} onChange={(event) => setDraft((prev) => ({ ...prev, description: event.target.value }))} />
<label className="field-label">{isZh ? '排序值' : 'Sort Order'}</label>
<input className="input" type="number" value={draft.sort_order} onChange={(event) => setDraft((prev) => ({ ...prev, sort_order: event.target.value }))} />
<label className="field-label">
<input type="checkbox" checked={draft.is_public} onChange={(event) => setDraft((prev) => ({ ...prev, is_public: event.target.checked }))} style={{ marginRight: 8 }} />
{isZh ? '前端可访问' : 'Public to frontend'}
</label>
<div className="row-between">
<button className="btn btn-secondary" type="button" onClick={() => setShowEditor(false)}>{isZh ? '取消' : 'Cancel'}</button>
<button
className="btn btn-primary"
type="button"
disabled={saving}
onClick={() => {
void (async () => {
setSaving(true);
try {
const payload = {
key: draft.key.trim(),
name: draft.name.trim(),
category: draft.category.trim(),
description: draft.description.trim(),
value_type: draft.value_type,
value: parseValue(draft),
is_public: draft.is_public,
sort_order: Number(draft.sort_order || '100') || 100,
};
if (editingKey) {
await axios.put(`${APP_ENDPOINTS.apiBase}/platform/system-settings/${encodeURIComponent(editingKey)}`, payload);
} else {
await axios.post(`${APP_ENDPOINTS.apiBase}/platform/system-settings`, payload);
}
await loadRows();
await refreshSnapshot();
notify(isZh ? '系统参数已保存。' : 'System setting saved.', { tone: 'success' });
setShowEditor(false);
} catch (error: any) {
const detail = error instanceof SyntaxError
? (isZh ? 'JSON 参数格式错误。' : 'Invalid JSON value.')
: (error?.response?.data?.detail || (isZh ? '保存参数失败。' : 'Failed to save setting.'));
notify(detail, { tone: 'error' });
} finally {
setSaving(false);
}
})();
}}
>
{saving ? <RefreshCw size={14} className="animate-spin" /> : null}
<span style={{ marginLeft: saving ? 6 : 0 }}>{isZh ? '保存' : 'Save'}</span>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}