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:
@@ -8,6 +8,7 @@ import Home from './pages/Home';
|
||||
import Roles from './pages/Roles';
|
||||
import Users from './pages/Users';
|
||||
import Organizations from './pages/Organizations';
|
||||
import Settings from './pages/Settings';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import { useAppStore } from './stores/app';
|
||||
|
||||
@@ -45,7 +46,7 @@ export default function App() {
|
||||
<Route path="/users" element={<Users />} />
|
||||
<Route path="/roles" element={<Roles />} />
|
||||
<Route path="/organizations" element={<Organizations />} />
|
||||
<Route path="/settings" element={<div>系统设置(开发中)</div>} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</MainLayout>
|
||||
</PrivateRoute>
|
||||
|
||||
66
apps/web/src/api/dictionaries.ts
Normal file
66
apps/web/src/api/dictionaries.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface DictionaryItemInfo {
|
||||
id: string;
|
||||
dictionary_id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
sort_order: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface DictionaryInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
items: DictionaryItemInfo[];
|
||||
}
|
||||
|
||||
export interface CreateDictionaryRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDictionaryRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export async function listDictionaries(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<DictionaryInfo> }>(
|
||||
'/config/dictionaries',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createDictionary(req: CreateDictionaryRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: DictionaryInfo }>(
|
||||
'/config/dictionaries',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateDictionary(id: string, req: UpdateDictionaryRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: DictionaryInfo }>(
|
||||
`/config/dictionaries/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function deleteDictionary(id: string) {
|
||||
await client.delete(`/config/dictionaries/${id}`);
|
||||
}
|
||||
|
||||
export async function listItemsByCode(code: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: DictionaryItemInfo[] }>(
|
||||
'/config/dictionaries/items',
|
||||
{ params: { code } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
36
apps/web/src/api/menus.ts
Normal file
36
apps/web/src/api/menus.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import client from './client';
|
||||
|
||||
export interface MenuInfo {
|
||||
id: string;
|
||||
parent_id?: string;
|
||||
title: string;
|
||||
path?: string;
|
||||
icon?: string;
|
||||
sort_order: number;
|
||||
visible: boolean;
|
||||
menu_type: string;
|
||||
permission?: string;
|
||||
children: MenuInfo[];
|
||||
}
|
||||
|
||||
export interface MenuItemReq {
|
||||
id?: string;
|
||||
parent_id?: string;
|
||||
title: string;
|
||||
path?: string;
|
||||
icon?: string;
|
||||
sort_order?: number;
|
||||
visible?: boolean;
|
||||
menu_type?: string;
|
||||
permission?: string;
|
||||
role_ids?: string[];
|
||||
}
|
||||
|
||||
export async function getMenus() {
|
||||
const { data } = await client.get<{ success: boolean; data: MenuInfo[] }>('/config/menus');
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function batchSaveMenus(menus: MenuItemReq[]) {
|
||||
await client.put('/config/menus', { menus });
|
||||
}
|
||||
67
apps/web/src/api/numberingRules.ts
Normal file
67
apps/web/src/api/numberingRules.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import client from './client';
|
||||
import type { PaginatedResponse } from './users';
|
||||
|
||||
export interface NumberingRuleInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
prefix: string;
|
||||
date_format?: string;
|
||||
seq_length: number;
|
||||
seq_start: number;
|
||||
seq_current: number;
|
||||
separator: string;
|
||||
reset_cycle: string;
|
||||
last_reset_date?: string;
|
||||
}
|
||||
|
||||
export interface CreateNumberingRuleRequest {
|
||||
name: string;
|
||||
code: string;
|
||||
prefix?: string;
|
||||
date_format?: string;
|
||||
seq_length?: number;
|
||||
seq_start?: number;
|
||||
separator?: string;
|
||||
reset_cycle?: string;
|
||||
}
|
||||
|
||||
export interface UpdateNumberingRuleRequest {
|
||||
name?: string;
|
||||
prefix?: string;
|
||||
date_format?: string;
|
||||
seq_length?: number;
|
||||
separator?: string;
|
||||
reset_cycle?: string;
|
||||
}
|
||||
|
||||
export async function listNumberingRules(page = 1, pageSize = 20) {
|
||||
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<NumberingRuleInfo> }>(
|
||||
'/config/numbering-rules',
|
||||
{ params: { page, page_size: pageSize } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function createNumberingRule(req: CreateNumberingRuleRequest) {
|
||||
const { data } = await client.post<{ success: boolean; data: NumberingRuleInfo }>(
|
||||
'/config/numbering-rules',
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateNumberingRule(id: string, req: UpdateNumberingRuleRequest) {
|
||||
const { data } = await client.put<{ success: boolean; data: NumberingRuleInfo }>(
|
||||
`/config/numbering-rules/${id}`,
|
||||
req,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function generateNumber(id: string) {
|
||||
const { data } = await client.post<{ success: boolean; data: { number: string } }>(
|
||||
`/config/numbering-rules/${id}/generate`,
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
25
apps/web/src/api/settings.ts
Normal file
25
apps/web/src/api/settings.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import client from './client';
|
||||
|
||||
export interface SettingInfo {
|
||||
id: string;
|
||||
scope: string;
|
||||
scope_id?: string;
|
||||
setting_key: string;
|
||||
setting_value: unknown;
|
||||
}
|
||||
|
||||
export async function getSetting(key: string, scope?: string, scopeId?: string) {
|
||||
const { data } = await client.get<{ success: boolean; data: SettingInfo }>(
|
||||
`/config/settings/${key}`,
|
||||
{ params: { scope, scope_id: scopeId } },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function updateSetting(key: string, settingValue: unknown) {
|
||||
const { data } = await client.put<{ success: boolean; data: SettingInfo }>(
|
||||
`/config/settings/${key}`,
|
||||
{ setting_value: settingValue },
|
||||
);
|
||||
return data.data;
|
||||
}
|
||||
20
apps/web/src/pages/Settings.tsx
Normal file
20
apps/web/src/pages/Settings.tsx
Normal 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;
|
||||
358
apps/web/src/pages/settings/DictionaryManager.tsx
Normal file
358
apps/web/src/pages/settings/DictionaryManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
apps/web/src/pages/settings/MenuConfig.tsx
Normal file
321
apps/web/src/pages/settings/MenuConfig.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
298
apps/web/src/pages/settings/NumberingRules.tsx
Normal file
298
apps/web/src/pages/settings/NumberingRules.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
apps/web/src/pages/settings/SystemSettings.tsx
Normal file
225
apps/web/src/pages/settings/SystemSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
apps/web/src/pages/settings/ThemeSettings.tsx
Normal file
104
apps/web/src/pages/settings/ThemeSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user