feat(config): add system configuration module (Phase 3)

Implement the complete erp-config crate with:
- Data dictionaries (CRUD + items management)
- Dynamic menus (tree structure with role filtering)
- System settings (hierarchical: platform > tenant > org > user)
- Numbering rules (concurrency-safe via PostgreSQL advisory_lock)
- Theme and language configuration (via settings store)
- 6 database migrations (dictionaries, menus, settings, numbering_rules)
- Frontend Settings page with 5 tabs (dictionary, menu, numbering, settings, theme)

Refactor: move RBAC functions (require_permission) from erp-auth to erp-core
to avoid cross-module dependencies.

Add 20 new seed permissions for config module operations.
This commit is contained in:
iven
2026-04-11 08:09:19 +08:00
parent 8a012f6c6a
commit 0baaf5f7ee
55 changed files with 5295 additions and 12 deletions

View File

@@ -0,0 +1,20 @@
import { Tabs } from 'antd';
import DictionaryManager from './settings/DictionaryManager';
import MenuConfig from './settings/MenuConfig';
import NumberingRules from './settings/NumberingRules';
import SystemSettings from './settings/SystemSettings';
import ThemeSettings from './settings/ThemeSettings';
const Settings: React.FC = () => {
const items = [
{ key: 'dictionaries', label: '数据字典', children: <DictionaryManager /> },
{ key: 'menus', label: '菜单配置', children: <MenuConfig /> },
{ key: 'numbering', label: '编号规则', children: <NumberingRules /> },
{ key: 'settings', label: '系统参数', children: <SystemSettings /> },
{ key: 'theme', label: '主题设置', children: <ThemeSettings /> },
];
return <Tabs defaultActiveKey="dictionaries" items={items} />;
};
export default Settings;

View File

@@ -0,0 +1,358 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
Popconfirm,
message,
Typography,
Tag,
} from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import client from '../../api/client';
// --- Types ---
interface DictItem {
id: string;
label: string;
value: string;
sort_order: number;
color?: string;
}
interface Dictionary {
id: string;
name: string;
code: string;
description?: string;
items: DictItem[];
}
// --- Component ---
export default function DictionaryManager() {
const [dictionaries, setDictionaries] = useState<Dictionary[]>([]);
const [loading, setLoading] = useState(false);
const [dictModalOpen, setDictModalOpen] = useState(false);
const [editDict, setEditDict] = useState<Dictionary | null>(null);
const [itemModalOpen, setItemModalOpen] = useState(false);
const [activeDictId, setActiveDictId] = useState<string | null>(null);
const [editItem, setEditItem] = useState<DictItem | null>(null);
const [dictForm] = Form.useForm();
const [itemForm] = Form.useForm();
const fetchDictionaries = useCallback(async () => {
setLoading(true);
try {
const { data: resp } = await client.get('/config/dictionaries');
setDictionaries(resp.data ?? resp);
} catch {
message.error('加载字典列表失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchDictionaries();
}, [fetchDictionaries]);
// --- Dictionary CRUD ---
const handleDictSubmit = async (values: {
name: string;
code: string;
description?: string;
}) => {
try {
if (editDict) {
await client.put(`/config/dictionaries/${editDict.id}`, values);
message.success('字典更新成功');
} else {
await client.post('/config/dictionaries', values);
message.success('字典创建成功');
}
closeDictModal();
fetchDictionaries();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDeleteDict = async (id: string) => {
try {
await client.delete(`/config/dictionaries/${id}`);
message.success('字典已删除');
fetchDictionaries();
} catch {
message.error('删除失败');
}
};
const openEditDict = (dict: Dictionary) => {
setEditDict(dict);
dictForm.setFieldsValue({
name: dict.name,
code: dict.code,
description: dict.description,
});
setDictModalOpen(true);
};
const openCreateDict = () => {
setEditDict(null);
dictForm.resetFields();
setDictModalOpen(true);
};
const closeDictModal = () => {
setDictModalOpen(false);
setEditDict(null);
dictForm.resetFields();
};
// --- Dictionary Item CRUD ---
const openAddItem = (dictId: string) => {
setActiveDictId(dictId);
setEditItem(null);
itemForm.resetFields();
setItemModalOpen(true);
};
const openEditItem = (dictId: string, item: DictItem) => {
setActiveDictId(dictId);
setEditItem(item);
itemForm.setFieldsValue({
label: item.label,
value: item.value,
sort_order: item.sort_order,
color: item.color,
});
setItemModalOpen(true);
};
const handleItemSubmit = async (values: {
label: string;
value: string;
sort_order: number;
color?: string;
}) => {
if (!activeDictId) return;
try {
if (editItem) {
await client.put(
`/config/dictionaries/${activeDictId}/items/${editItem.id}`,
values,
);
message.success('字典项更新成功');
} else {
await client.post(`/config/dictionaries/${activeDictId}/items`, values);
message.success('字典项添加成功');
}
closeItemModal();
fetchDictionaries();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDeleteItem = async (dictId: string, itemId: string) => {
try {
await client.delete(`/config/dictionaries/${dictId}/items/${itemId}`);
message.success('字典项已删除');
fetchDictionaries();
} catch {
message.error('删除失败');
}
};
const closeItemModal = () => {
setItemModalOpen(false);
setActiveDictId(null);
setEditItem(null);
itemForm.resetFields();
};
// --- Columns ---
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '编码', dataIndex: 'code', key: 'code' },
{
title: '说明',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '操作',
key: 'actions',
render: (_: unknown, record: Dictionary) => (
<Space>
<Button size="small" onClick={() => openAddItem(record.id)}>
</Button>
<Button size="small" onClick={() => openEditDict(record)}>
</Button>
<Popconfirm
title="确定删除此字典?"
onConfirm={() => handleDeleteDict(record.id)}
>
<Button size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
const itemColumns = (dictId: string) => [
{ title: '标签', dataIndex: 'label', key: 'label' },
{ title: '值', dataIndex: 'value', key: 'value' },
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order', width: 80 },
{
title: '颜色',
dataIndex: 'color',
key: 'color',
width: 80,
render: (color?: string) =>
color ? <Tag color={color}>{color}</Tag> : '-',
},
{
title: '操作',
key: 'actions',
render: (_: unknown, record: DictItem) => (
<Space>
<Button
size="small"
onClick={() => openEditItem(dictId, record)}
>
</Button>
<Popconfirm
title="确定删除此字典项?"
onConfirm={() => handleDeleteItem(dictId, record.id)}
>
<Button size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateDict}>
</Button>
</div>
<Table
columns={columns}
dataSource={dictionaries}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
expandable={{
expandedRowRender: (record) => (
<Table
columns={itemColumns(record.id)}
dataSource={record.items}
rowKey="id"
size="small"
pagination={false}
/>
),
}}
/>
{/* Dictionary Modal */}
<Modal
title={editDict ? '编辑字典' : '新建字典'}
open={dictModalOpen}
onCancel={closeDictModal}
onOk={() => dictForm.submit()}
>
<Form form={dictForm} onFinish={handleDictSubmit} layout="vertical">
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入字典名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="code"
label="编码"
rules={[{ required: true, message: '请输入字典编码' }]}
>
<Input disabled={!!editDict} />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
{/* Dictionary Item Modal */}
<Modal
title={editItem ? '编辑字典项' : '添加字典项'}
open={itemModalOpen}
onCancel={closeItemModal}
onOk={() => itemForm.submit()}
>
<Form form={itemForm} onFinish={handleItemSubmit} layout="vertical">
<Form.Item
name="label"
label="标签"
rules={[{ required: true, message: '请输入标签' }]}
>
<Input />
</Form.Item>
<Form.Item
name="value"
label="值"
rules={[{ required: true, message: '请输入值' }]}
>
<Input />
</Form.Item>
<Form.Item
name="sort_order"
label="排序"
initialValue={0}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="color" label="颜色">
<Input placeholder="如blue, red, green 或十六进制色值" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,321 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
Select,
Switch,
TreeSelect,
Popconfirm,
message,
Typography,
Tag,
} from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import client from '../../api/client';
// --- Types ---
interface MenuItem {
id: string;
parent_id?: string | null;
title: string;
path?: string;
icon?: string;
menu_type: 'directory' | 'menu' | 'button';
sort_order: number;
visible: boolean;
permission?: string;
children?: MenuItem[];
}
// --- Helpers ---
/** Convert flat menu list to tree structure for Table children prop */
function buildMenuTree(items: MenuItem[]): MenuItem[] {
const map = new Map<string, MenuItem>();
const roots: MenuItem[] = [];
const withChildren = items.map((item) => ({
...item,
children: [] as MenuItem[],
}));
withChildren.forEach((item) => map.set(item.id, item));
withChildren.forEach((item) => {
if (item.parent_id && map.has(item.parent_id)) {
map.get(item.parent_id)!.children!.push(item);
} else {
roots.push(item);
}
});
return roots;
}
/** Convert menu tree to TreeSelect data nodes */
function toTreeSelectData(
items: MenuItem[],
): Array<{ title: string; value: string; children?: Array<{ title: string; value: string }> }> {
return items.map((item) => ({
title: item.title,
value: item.id,
children:
item.children && item.children.length > 0
? toTreeSelectData(item.children)
: undefined,
}));
}
const menuTypeLabels: Record<string, { text: string; color: string }> = {
directory: { text: '目录', color: 'blue' },
menu: { text: '菜单', color: 'green' },
button: { text: '按钮', color: 'orange' },
};
// --- Component ---
export default function MenuConfig() {
const [menus, setMenus] = useState<MenuItem[]>([]);
const [menuTree, setMenuTree] = useState<MenuItem[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editMenu, setEditMenu] = useState<MenuItem | null>(null);
const [form] = Form.useForm();
const fetchMenus = useCallback(async () => {
setLoading(true);
try {
const { data: resp } = await client.get('/config/menus');
const list: MenuItem[] = resp.data ?? resp;
setMenus(list);
setMenuTree(buildMenuTree(list));
} catch {
message.error('加载菜单失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchMenus();
}, [fetchMenus]);
const handleSubmit = async (values: {
parent_id?: string;
title: string;
path?: string;
icon?: string;
menu_type: 'directory' | 'menu' | 'button';
sort_order: number;
visible: boolean;
permission?: string;
}) => {
try {
if (editMenu) {
await client.put(`/config/menus/${editMenu.id}`, values);
message.success('菜单更新成功');
} else {
await client.post('/config/menus', values);
message.success('菜单创建成功');
}
closeModal();
fetchMenus();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDelete = async (id: string) => {
try {
await client.delete(`/config/menus/${id}`);
message.success('菜单已删除');
fetchMenus();
} catch {
message.error('删除失败');
}
};
const openCreate = () => {
setEditMenu(null);
form.resetFields();
form.setFieldsValue({
menu_type: 'menu',
sort_order: 0,
visible: true,
});
setModalOpen(true);
};
const openEdit = (menu: MenuItem) => {
setEditMenu(menu);
form.setFieldsValue({
parent_id: menu.parent_id || undefined,
title: menu.title,
path: menu.path,
icon: menu.icon,
menu_type: menu.menu_type,
sort_order: menu.sort_order,
visible: menu.visible,
permission: menu.permission,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditMenu(null);
form.resetFields();
};
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', width: 200 },
{
title: '路径',
dataIndex: 'path',
key: 'path',
ellipsis: true,
render: (v?: string) => v || '-',
},
{
title: '图标',
dataIndex: 'icon',
key: 'icon',
width: 100,
render: (v?: string) => v || '-',
},
{
title: '类型',
dataIndex: 'menu_type',
key: 'menu_type',
width: 90,
render: (v: string) => {
const info = menuTypeLabels[v] ?? { text: v, color: 'default' };
return <Tag color={info.color}>{info.text}</Tag>;
},
},
{
title: '排序',
dataIndex: 'sort_order',
key: 'sort_order',
width: 80,
},
{
title: '可见',
dataIndex: 'visible',
key: 'visible',
width: 80,
render: (v: boolean) =>
v ? <Tag color="green"></Tag> : <Tag color="default"></Tag>,
},
{
title: '操作',
key: 'actions',
width: 150,
render: (_: unknown, record: MenuItem) => (
<Space>
<Button size="small" onClick={() => openEdit(record)}>
</Button>
<Popconfirm
title="确定删除此菜单?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</div>
<Table
columns={columns}
dataSource={menuTree}
rowKey="id"
loading={loading}
pagination={false}
indentSize={20}
/>
<Modal
title={editMenu ? '编辑菜单' : '添加菜单'}
open={modalOpen}
onCancel={closeModal}
onOk={() => form.submit()}
width={560}
>
<Form form={form} onFinish={handleSubmit} layout="vertical">
<Form.Item name="parent_id" label="上级菜单">
<TreeSelect
treeData={toTreeSelectData(menuTree)}
placeholder="无(顶级菜单)"
allowClear
treeDefaultExpandAll
/>
</Form.Item>
<Form.Item
name="title"
label="标题"
rules={[{ required: true, message: '请输入菜单标题' }]}
>
<Input />
</Form.Item>
<Form.Item name="path" label="路径">
<Input placeholder="/example/path" />
</Form.Item>
<Form.Item name="icon" label="图标">
<Input placeholder="图标名称,如 HomeOutlined" />
</Form.Item>
<Form.Item
name="menu_type"
label="类型"
rules={[{ required: true, message: '请选择菜单类型' }]}
>
<Select
options={[
{ label: '目录', value: 'directory' },
{ label: '菜单', value: 'menu' },
{ label: '按钮', value: 'button' },
]}
/>
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="visible" label="可见" valuePropName="checked" initialValue>
<Switch />
</Form.Item>
<Form.Item name="permission" label="权限标识">
<Input placeholder="如 system:user:list" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,298 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
Select,
Popconfirm,
message,
Typography,
} from 'antd';
import { PlusOutlined, NumberOutlined } from '@ant-design/icons';
import client from '../../api/client';
// --- Types ---
interface NumberingRule {
id: string;
name: string;
code: string;
prefix?: string;
date_format?: string;
seq_length: number;
seq_start: number;
current_value: number;
separator?: string;
reset_cycle: 'never' | 'daily' | 'monthly' | 'yearly';
}
// --- Constants ---
const resetCycleOptions = [
{ label: '不重置', value: 'never' },
{ label: '每天', value: 'daily' },
{ label: '每月', value: 'monthly' },
{ label: '每年', value: 'yearly' },
];
const resetCycleLabels: Record<string, string> = {
never: '不重置',
daily: '每天',
monthly: '每月',
yearly: '每年',
};
// --- Component ---
export default function NumberingRules() {
const [rules, setRules] = useState<NumberingRule[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editRule, setEditRule] = useState<NumberingRule | null>(null);
const [form] = Form.useForm();
const fetchRules = useCallback(async () => {
setLoading(true);
try {
const { data: resp } = await client.get('/config/numbering-rules');
setRules(resp.data ?? resp);
} catch {
message.error('加载编号规则失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchRules();
}, [fetchRules]);
const handleSubmit = async (values: {
name: string;
code: string;
prefix?: string;
date_format?: string;
seq_length: number;
seq_start: number;
separator?: string;
reset_cycle: 'never' | 'daily' | 'monthly' | 'yearly';
}) => {
try {
if (editRule) {
await client.put(`/config/numbering-rules/${editRule.id}`, values);
message.success('编号规则更新成功');
} else {
await client.post('/config/numbering-rules', values);
message.success('编号规则创建成功');
}
closeModal();
fetchRules();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDelete = async (id: string) => {
try {
await client.delete(`/config/numbering-rules/${id}`);
message.success('编号规则已删除');
fetchRules();
} catch {
message.error('删除失败');
}
};
const handleGenerate = async (rule: NumberingRule) => {
try {
const { data: resp } = await client.post(
`/config/numbering-rules/${rule.id}/generate`,
);
const generated = resp.data?.number ?? resp.data ?? resp.number ?? resp;
message.success(`生成编号: ${generated}`);
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '生成编号失败';
message.error(errorMsg);
}
};
const openCreate = () => {
setEditRule(null);
form.resetFields();
form.setFieldsValue({
seq_length: 4,
seq_start: 1,
separator: '-',
reset_cycle: 'never',
});
setModalOpen(true);
};
const openEdit = (rule: NumberingRule) => {
setEditRule(rule);
form.setFieldsValue({
name: rule.name,
code: rule.code,
prefix: rule.prefix,
date_format: rule.date_format,
seq_length: rule.seq_length,
seq_start: rule.seq_start,
separator: rule.separator,
reset_cycle: rule.reset_cycle,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditRule(null);
form.resetFields();
};
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '编码', dataIndex: 'code', key: 'code' },
{
title: '前缀',
dataIndex: 'prefix',
key: 'prefix',
render: (v?: string) => v || '-',
},
{
title: '日期格式',
dataIndex: 'date_format',
key: 'date_format',
render: (v?: string) => v || '-',
},
{
title: '序列长度',
dataIndex: 'seq_length',
key: 'seq_length',
width: 90,
},
{
title: '当前值',
dataIndex: 'current_value',
key: 'current_value',
width: 90,
},
{
title: '重置周期',
dataIndex: 'reset_cycle',
key: 'reset_cycle',
width: 100,
render: (v: string) => resetCycleLabels[v] ?? v,
},
{
title: '操作',
key: 'actions',
render: (_: unknown, record: NumberingRule) => (
<Space>
<Button
size="small"
icon={<NumberOutlined />}
onClick={() => handleGenerate(record)}
>
</Button>
<Button size="small" onClick={() => openEdit(record)}>
</Button>
<Popconfirm
title="确定删除此编号规则?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</div>
<Table
columns={columns}
dataSource={rules}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
/>
<Modal
title={editRule ? '编辑编号规则' : '新建编号规则'}
open={modalOpen}
onCancel={closeModal}
onOk={() => form.submit()}
width={560}
>
<Form form={form} onFinish={handleSubmit} layout="vertical">
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入规则名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="code"
label="编码"
rules={[{ required: true, message: '请输入规则编码' }]}
>
<Input disabled={!!editRule} />
</Form.Item>
<Form.Item name="prefix" label="前缀">
<Input placeholder="如 PO、SO" />
</Form.Item>
<Form.Item name="date_format" label="日期格式">
<Input placeholder="如 YYYYMMDD" />
</Form.Item>
<Form.Item name="separator" label="分隔符">
<Input placeholder="默认 -" />
</Form.Item>
<Form.Item
name="seq_length"
label="序列长度"
rules={[{ required: true, message: '请输入序列长度' }]}
>
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="seq_start" label="起始值" initialValue={1}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="reset_cycle"
label="重置周期"
rules={[{ required: true, message: '请选择重置周期' }]}
>
<Select options={resetCycleOptions} />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,225 @@
import { useState } from 'react';
import {
Button,
Form,
Input,
Space,
Popconfirm,
message,
Typography,
Table,
Modal,
} from 'antd';
import { PlusOutlined, SearchOutlined } from '@ant-design/icons';
import client from '../../api/client';
// --- Types ---
interface SettingEntry {
key: string;
value: string;
}
// --- Component ---
export default function SystemSettings() {
const [entries, setEntries] = useState<SettingEntry[]>([]);
const [searchKey, setSearchKey] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [editEntry, setEditEntry] = useState<SettingEntry | null>(null);
const [form] = Form.useForm();
const handleSearch = async () => {
if (!searchKey.trim()) {
message.warning('请输入设置键名');
return;
}
try {
const { data: resp } = await client.get(
`/config/settings/${encodeURIComponent(searchKey.trim())}`,
);
const value = resp.data?.setting_value ?? resp.data?.value ?? resp.setting_value ?? resp.value ?? '';
// Check if already in local list
setEntries((prev) => {
const exists = prev.findIndex((e) => e.key === searchKey.trim());
if (exists >= 0) {
const updated = [...prev];
updated[exists] = { ...updated[exists], value: String(value) };
return updated;
}
return [...prev, { key: searchKey.trim(), value: String(value) }];
});
message.success('查询成功');
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 404) {
message.info('该设置键不存在,可点击"添加设置"创建');
} else {
message.error('查询失败');
}
}
};
const handleSave = async (values: { setting_key: string; setting_value: string }) => {
const key = values.setting_key.trim();
const value = values.setting_value;
try {
// Validate JSON
try {
JSON.parse(value);
} catch {
message.error('设置值必须是有效的 JSON 格式');
return;
}
await client.put(`/config/settings/${encodeURIComponent(key)}`, {
setting_value: value,
});
setEntries((prev) => {
const exists = prev.findIndex((e) => e.key === key);
if (exists >= 0) {
const updated = [...prev];
updated[exists] = { key, value };
return updated;
}
return [...prev, { key, value }];
});
message.success('设置已保存');
closeModal();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '保存失败';
message.error(errorMsg);
}
};
const handleDelete = async (key: string) => {
try {
await client.delete(`/config/settings/${encodeURIComponent(key)}`);
setEntries((prev) => prev.filter((e) => e.key !== key));
message.success('设置已删除');
} catch {
message.error('删除失败');
}
};
const openCreate = () => {
setEditEntry(null);
form.resetFields();
setModalOpen(true);
};
const openEdit = (entry: SettingEntry) => {
setEditEntry(entry);
form.setFieldsValue({
setting_key: entry.key,
setting_value: entry.value,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditEntry(null);
form.resetFields();
};
const columns = [
{ title: '键', dataIndex: 'key', key: 'key', width: 250 },
{
title: '值 (JSON)',
dataIndex: 'value',
key: 'value',
ellipsis: true,
},
{
title: '操作',
key: 'actions',
width: 180,
render: (_: unknown, record: SettingEntry) => (
<Space>
<Button size="small" onClick={() => openEdit(record)}>
</Button>
<Popconfirm
title="确定删除此设置?"
onConfirm={() => handleDelete(record.key)}
>
<Button size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</div>
<Space style={{ marginBottom: 16 }} size="middle">
<Input
placeholder="输入设置键名查询"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 300 }}
/>
<Button icon={<SearchOutlined />} onClick={handleSearch}>
</Button>
</Space>
<Table
columns={columns}
dataSource={entries}
rowKey="key"
size="small"
pagination={false}
/>
<Modal
title={editEntry ? '编辑设置' : '添加设置'}
open={modalOpen}
onCancel={closeModal}
onOk={() => form.submit()}
width={560}
>
<Form form={form} onFinish={handleSave} layout="vertical">
<Form.Item
name="setting_key"
label="键名"
rules={[{ required: true, message: '请输入设置键名' }]}
>
<Input disabled={!!editEntry} />
</Form.Item>
<Form.Item
name="setting_value"
label="值 (JSON)"
rules={[{ required: true, message: '请输入设置值' }]}
>
<Input.TextArea rows={6} placeholder='{"key": "value"}' />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { useState, useEffect, useCallback } from 'react';
import { Form, Input, Select, Button, ColorPicker, message, Typography } from 'antd';
import client from '../../api/client';
// --- Types ---
interface ThemeConfig {
primary_color?: string;
logo_url?: string;
sidebar_style?: 'light' | 'dark';
}
// --- Component ---
export default function ThemeSettings() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const fetchTheme = useCallback(async () => {
setLoading(true);
try {
const { data: resp } = await client.get('/config/themes');
const theme: ThemeConfig = resp.data ?? resp;
form.setFieldsValue({
primary_color: theme.primary_color || '#1677ff',
logo_url: theme.logo_url || '',
sidebar_style: theme.sidebar_style || 'light',
});
} catch {
// Theme may not exist yet; use defaults
form.setFieldsValue({
primary_color: '#1677ff',
logo_url: '',
sidebar_style: 'light',
});
}
setLoading(false);
}, [form]);
useEffect(() => {
fetchTheme();
}, [fetchTheme]);
const handleSave = async (values: {
primary_color: string;
logo_url: string;
sidebar_style: 'light' | 'dark';
}) => {
setSaving(true);
try {
await client.put('/config/themes', {
primary_color:
typeof values.primary_color === 'string'
? values.primary_color
: (values.primary_color as { toHexString?: () => string }).toHexString?.() ?? String(values.primary_color),
logo_url: values.logo_url,
sidebar_style: values.sidebar_style,
});
message.success('主题设置已保存');
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '保存失败';
message.error(errorMsg);
}
setSaving(false);
};
return (
<div>
<Typography.Title level={5} style={{ marginBottom: 16 }}>
</Typography.Title>
<Form
form={form}
onFinish={handleSave}
layout="vertical"
style={{ maxWidth: 480 }}
>
<Form.Item name="primary_color" label="主色调">
<ColorPicker format="hex" />
</Form.Item>
<Form.Item name="logo_url" label="Logo URL">
<Input placeholder="https://example.com/logo.png" />
</Form.Item>
<Form.Item name="sidebar_style" label="侧边栏风格">
<Select
options={[
{ label: '亮色', value: 'light' },
{ label: '暗色', value: 'dark' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving}>
</Button>
</Form.Item>
</Form>
</div>
);
}