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,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>
);
}