384 lines
17 KiB
TypeScript
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>
|
|
);
|
|
}
|