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:
5
Cargo.lock
generated
5
Cargo.lock
generated
@@ -838,15 +838,19 @@ name = "erp-config"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
"erp-core",
|
"erp-core",
|
||||||
"sea-orm",
|
"sea-orm",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"utoipa",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"validator",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -891,6 +895,7 @@ dependencies = [
|
|||||||
"config",
|
"config",
|
||||||
"erp-auth",
|
"erp-auth",
|
||||||
"erp-common",
|
"erp-common",
|
||||||
|
"erp-config",
|
||||||
"erp-core",
|
"erp-core",
|
||||||
"erp-server-migration",
|
"erp-server-migration",
|
||||||
"redis",
|
"redis",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import Home from './pages/Home';
|
|||||||
import Roles from './pages/Roles';
|
import Roles from './pages/Roles';
|
||||||
import Users from './pages/Users';
|
import Users from './pages/Users';
|
||||||
import Organizations from './pages/Organizations';
|
import Organizations from './pages/Organizations';
|
||||||
|
import Settings from './pages/Settings';
|
||||||
import { useAuthStore } from './stores/auth';
|
import { useAuthStore } from './stores/auth';
|
||||||
import { useAppStore } from './stores/app';
|
import { useAppStore } from './stores/app';
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ export default function App() {
|
|||||||
<Route path="/users" element={<Users />} />
|
<Route path="/users" element={<Users />} />
|
||||||
<Route path="/roles" element={<Roles />} />
|
<Route path="/roles" element={<Roles />} />
|
||||||
<Route path="/organizations" element={<Organizations />} />
|
<Route path="/organizations" element={<Organizations />} />
|
||||||
<Route path="/settings" element={<div>系统设置(开发中)</div>} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MainLayout>
|
</MainLayout>
|
||||||
</PrivateRoute>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ use crate::dto::{
|
|||||||
CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp,
|
CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp,
|
||||||
OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq,
|
OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq,
|
||||||
};
|
};
|
||||||
use crate::middleware::rbac::require_permission;
|
use erp_core::rbac::require_permission;
|
||||||
use crate::service::dept_service::DeptService;
|
use crate::service::dept_service::DeptService;
|
||||||
use crate::service::org_service::OrgService;
|
use crate::service::org_service::OrgService;
|
||||||
use crate::service::position_service::PositionService;
|
use crate::service::position_service::PositionService;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::auth_state::AuthState;
|
use crate::auth_state::AuthState;
|
||||||
use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq};
|
use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq};
|
||||||
use crate::middleware::rbac::require_permission;
|
use erp_core::rbac::require_permission;
|
||||||
use crate::service::permission_service::PermissionService;
|
use crate::service::permission_service::PermissionService;
|
||||||
use crate::service::role_service::RoleService;
|
use crate::service::role_service::RoleService;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::auth_state::AuthState;
|
use crate::auth_state::AuthState;
|
||||||
use crate::dto::{CreateUserReq, UpdateUserReq, UserResp};
|
use crate::dto::{CreateUserReq, UpdateUserReq, UserResp};
|
||||||
use crate::middleware::rbac::require_permission;
|
use erp_core::rbac::require_permission;
|
||||||
use crate::service::user_service::UserService;
|
use crate::service::user_service::UserService;
|
||||||
|
|
||||||
/// GET /api/v1/users
|
/// GET /api/v1/users
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
pub mod jwt_auth;
|
pub mod jwt_auth;
|
||||||
pub mod rbac;
|
|
||||||
|
|
||||||
pub use jwt_auth::jwt_auth_middleware_fn;
|
pub use jwt_auth::jwt_auth_middleware_fn;
|
||||||
pub use rbac::{require_any_permission, require_permission, require_role};
|
pub use erp_core::rbac::{require_any_permission, require_permission, require_role};
|
||||||
|
|||||||
@@ -84,10 +84,27 @@ const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[
|
|||||||
("position:read", "查看岗位", "position", "read", "查看岗位"),
|
("position:read", "查看岗位", "position", "read", "查看岗位"),
|
||||||
("position:update", "编辑岗位", "position", "update", "编辑岗位"),
|
("position:update", "编辑岗位", "position", "update", "编辑岗位"),
|
||||||
("position:delete", "删除岗位", "position", "delete", "删除岗位"),
|
("position:delete", "删除岗位", "position", "delete", "删除岗位"),
|
||||||
|
// Config module permissions
|
||||||
|
("dictionary:create", "创建字典", "dictionary", "create", "创建数据字典"),
|
||||||
|
("dictionary:list", "查看字典", "dictionary", "list", "查看数据字典"),
|
||||||
|
("dictionary:update", "编辑字典", "dictionary", "update", "编辑数据字典"),
|
||||||
|
("dictionary:delete", "删除字典", "dictionary", "delete", "删除数据字典"),
|
||||||
|
("menu:list", "查看菜单", "menu", "list", "查看菜单配置"),
|
||||||
|
("menu:update", "编辑菜单", "menu", "update", "编辑菜单配置"),
|
||||||
|
("setting:read", "查看配置", "setting", "read", "查看系统参数"),
|
||||||
|
("setting:update", "编辑配置", "setting", "update", "编辑系统参数"),
|
||||||
|
("numbering:create", "创建编号规则", "numbering", "create", "创建编号规则"),
|
||||||
|
("numbering:list", "查看编号规则", "numbering", "list", "查看编号规则"),
|
||||||
|
("numbering:update", "编辑编号规则", "numbering", "update", "编辑编号规则"),
|
||||||
|
("numbering:generate", "生成编号", "numbering", "generate", "生成文档编号"),
|
||||||
|
("theme:read", "查看主题", "theme", "read", "查看主题设置"),
|
||||||
|
("theme:update", "编辑主题", "theme", "update", "编辑主题设置"),
|
||||||
|
("language:list", "查看语言", "language", "list", "查看语言配置"),
|
||||||
|
("language:update", "编辑语言", "language", "update", "编辑语言配置"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Indices of read-only permissions within DEFAULT_PERMISSIONS.
|
/// Indices of read-only permissions within DEFAULT_PERMISSIONS.
|
||||||
const READ_PERM_INDICES: &[usize] = &[1, 5, 9, 11, 15, 19];
|
const READ_PERM_INDICES: &[usize] = &[1, 5, 9, 11, 15, 19, 23, 24, 28, 29, 34, 38];
|
||||||
|
|
||||||
/// Seed default auth data for a new tenant.
|
/// Seed default auth data for a new tenant.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -14,3 +14,7 @@ axum.workspace = true
|
|||||||
sea-orm.workspace = true
|
sea-orm.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
validator.workspace = true
|
||||||
|
utoipa.workspace = true
|
||||||
|
async-trait.workspace = true
|
||||||
|
|||||||
11
crates/erp-config/src/config_state.rs
Normal file
11
crates/erp-config/src/config_state.rs
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
use erp_core::events::EventBus;
|
||||||
|
use sea_orm::DatabaseConnection;
|
||||||
|
|
||||||
|
/// Config-specific state extracted from the server's AppState via `FromRef`.
|
||||||
|
///
|
||||||
|
/// Contains the database connection and event bus needed by config handlers.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ConfigState {
|
||||||
|
pub db: DatabaseConnection,
|
||||||
|
pub event_bus: EventBus,
|
||||||
|
}
|
||||||
218
crates/erp-config/src/dto.rs
Normal file
218
crates/erp-config/src/dto.rs
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use utoipa::ToSchema;
|
||||||
|
use uuid::Uuid;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
// --- Dictionary DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct DictionaryItemResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub dictionary_id: Uuid,
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub sort_order: i32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct DictionaryResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub items: Vec<DictionaryItemResp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateDictionaryReq {
|
||||||
|
#[validate(length(min = 1, max = 100, message = "字典名称不能为空"))]
|
||||||
|
pub name: String,
|
||||||
|
#[validate(length(min = 1, max = 50, message = "字典编码不能为空"))]
|
||||||
|
pub code: String,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateDictionaryReq {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateDictionaryItemReq {
|
||||||
|
#[validate(length(min = 1, max = 100, message = "标签不能为空"))]
|
||||||
|
pub label: String,
|
||||||
|
#[validate(length(min = 1, max = 100, message = "值不能为空"))]
|
||||||
|
pub value: String,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateDictionaryItemReq {
|
||||||
|
pub label: Option<String>,
|
||||||
|
pub value: Option<String>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Menu DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema, Clone)]
|
||||||
|
pub struct MenuResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub visible: bool,
|
||||||
|
pub menu_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub permission: Option<String>,
|
||||||
|
pub children: Vec<MenuResp>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateMenuReq {
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
#[validate(length(min = 1, max = 100, message = "菜单标题不能为空"))]
|
||||||
|
pub title: String,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub visible: Option<bool>,
|
||||||
|
#[validate(length(min = 1, message = "菜单类型不能为空"))]
|
||||||
|
pub menu_type: Option<String>,
|
||||||
|
pub permission: Option<String>,
|
||||||
|
pub role_ids: Option<Vec<Uuid>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateMenuReq {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub visible: Option<bool>,
|
||||||
|
pub permission: Option<String>,
|
||||||
|
pub role_ids: Option<Vec<Uuid>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct BatchSaveMenusReq {
|
||||||
|
#[validate(length(min = 1, message = "菜单列表不能为空"))]
|
||||||
|
pub menus: Vec<MenuItemReq>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct MenuItemReq {
|
||||||
|
pub id: Option<Uuid>,
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
#[validate(length(min = 1, max = 100, message = "菜单标题不能为空"))]
|
||||||
|
pub title: String,
|
||||||
|
pub path: Option<String>,
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub sort_order: Option<i32>,
|
||||||
|
pub visible: Option<bool>,
|
||||||
|
pub menu_type: Option<String>,
|
||||||
|
pub permission: Option<String>,
|
||||||
|
pub role_ids: Option<Vec<Uuid>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Setting DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct SettingResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub scope: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub scope_id: Option<Uuid>,
|
||||||
|
pub setting_key: String,
|
||||||
|
pub setting_value: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct UpdateSettingReq {
|
||||||
|
pub setting_value: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Numbering Rule DTOs ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct NumberingRuleResp {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: String,
|
||||||
|
pub prefix: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub date_format: Option<String>,
|
||||||
|
pub seq_length: i32,
|
||||||
|
pub seq_start: i32,
|
||||||
|
pub seq_current: i64,
|
||||||
|
pub separator: String,
|
||||||
|
pub reset_cycle: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_reset_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Validate, ToSchema)]
|
||||||
|
pub struct CreateNumberingRuleReq {
|
||||||
|
#[validate(length(min = 1, max = 100, message = "规则名称不能为空"))]
|
||||||
|
pub name: String,
|
||||||
|
#[validate(length(min = 1, max = 50, message = "规则编码不能为空"))]
|
||||||
|
pub code: String,
|
||||||
|
pub prefix: Option<String>,
|
||||||
|
pub date_format: Option<String>,
|
||||||
|
pub seq_length: Option<i32>,
|
||||||
|
pub seq_start: Option<i32>,
|
||||||
|
pub separator: Option<String>,
|
||||||
|
pub reset_cycle: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateNumberingRuleReq {
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub prefix: Option<String>,
|
||||||
|
pub date_format: Option<String>,
|
||||||
|
pub seq_length: Option<i32>,
|
||||||
|
pub separator: Option<String>,
|
||||||
|
pub reset_cycle: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct GenerateNumberResp {
|
||||||
|
pub number: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Theme DTOs (stored via settings) ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)]
|
||||||
|
pub struct ThemeResp {
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub primary_color: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub logo_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub sidebar_style: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Language DTOs (stored via settings) ---
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, ToSchema)]
|
||||||
|
pub struct LanguageResp {
|
||||||
|
pub code: String,
|
||||||
|
pub name: String,
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateLanguageReq {
|
||||||
|
pub is_active: bool,
|
||||||
|
}
|
||||||
35
crates/erp-config/src/entity/dictionary.rs
Normal file
35
crates/erp-config/src/entity/dictionary.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "dictionaries")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::dictionary_item::Entity")]
|
||||||
|
DictionaryItem,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::dictionary_item::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::DictionaryItem.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
42
crates/erp-config/src/entity/dictionary_item.rs
Normal file
42
crates/erp-config/src/entity/dictionary_item.rs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "dictionary_items")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub dictionary_id: Uuid,
|
||||||
|
pub label: String,
|
||||||
|
pub value: String,
|
||||||
|
pub sort_order: i32,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::dictionary::Entity",
|
||||||
|
from = "Column::DictionaryId",
|
||||||
|
to = "super::dictionary::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Dictionary,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::dictionary::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Dictionary.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
43
crates/erp-config/src/entity/menu.rs
Normal file
43
crates/erp-config/src/entity/menu.rs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "menus")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_id: Option<Uuid>,
|
||||||
|
pub title: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub icon: Option<String>,
|
||||||
|
pub sort_order: i32,
|
||||||
|
pub visible: bool,
|
||||||
|
pub menu_type: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub permission: Option<String>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::menu_role::Entity")]
|
||||||
|
MenuRole,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::menu_role::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::MenuRole.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
38
crates/erp-config/src/entity/menu_role.rs
Normal file
38
crates/erp-config/src/entity/menu_role.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "menu_roles")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub menu_id: Uuid,
|
||||||
|
pub role_id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::menu::Entity",
|
||||||
|
from = "Column::MenuId",
|
||||||
|
to = "super::menu::Column::Id",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Menu,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::menu::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Menu.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
6
crates/erp-config/src/entity/mod.rs
Normal file
6
crates/erp-config/src/entity/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod dictionary;
|
||||||
|
pub mod dictionary_item;
|
||||||
|
pub mod menu;
|
||||||
|
pub mod menu_role;
|
||||||
|
pub mod setting;
|
||||||
|
pub mod numbering_rule;
|
||||||
34
crates/erp-config/src/entity/numbering_rule.rs
Normal file
34
crates/erp-config/src/entity/numbering_rule.rs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "numbering_rules")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub name: String,
|
||||||
|
pub code: String,
|
||||||
|
pub prefix: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub date_format: Option<String>,
|
||||||
|
pub seq_length: i32,
|
||||||
|
pub seq_start: i32,
|
||||||
|
pub seq_current: i64,
|
||||||
|
pub separator: String,
|
||||||
|
pub reset_cycle: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_reset_date: Option<chrono::NaiveDate>,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
27
crates/erp-config/src/entity/setting.rs
Normal file
27
crates/erp-config/src/entity/setting.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "settings")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub id: Uuid,
|
||||||
|
pub tenant_id: Uuid,
|
||||||
|
pub scope: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub scope_id: Option<Uuid>,
|
||||||
|
pub setting_key: String,
|
||||||
|
pub setting_value: serde_json::Value,
|
||||||
|
pub created_at: DateTimeUtc,
|
||||||
|
pub updated_at: DateTimeUtc,
|
||||||
|
pub created_by: Uuid,
|
||||||
|
pub updated_by: Uuid,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub deleted_at: Option<DateTimeUtc>,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
41
crates/erp-config/src/error.rs
Normal file
41
crates/erp-config/src/error.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use erp_core::error::AppError;
|
||||||
|
|
||||||
|
/// Config module error types.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum ConfigError {
|
||||||
|
#[error("验证失败: {0}")]
|
||||||
|
Validation(String),
|
||||||
|
|
||||||
|
#[error("资源未找到: {0}")]
|
||||||
|
NotFound(String),
|
||||||
|
|
||||||
|
#[error("键已存在: {0}")]
|
||||||
|
DuplicateKey(String),
|
||||||
|
|
||||||
|
#[error("编号序列耗尽: {0}")]
|
||||||
|
NumberingExhausted(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<sea_orm::TransactionError<ConfigError>> for ConfigError {
|
||||||
|
fn from(err: sea_orm::TransactionError<ConfigError>) -> Self {
|
||||||
|
match err {
|
||||||
|
sea_orm::TransactionError::Connection(err) => {
|
||||||
|
ConfigError::Validation(err.to_string())
|
||||||
|
}
|
||||||
|
sea_orm::TransactionError::Transaction(inner) => inner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ConfigError> for AppError {
|
||||||
|
fn from(err: ConfigError) -> Self {
|
||||||
|
match err {
|
||||||
|
ConfigError::Validation(s) => AppError::Validation(s),
|
||||||
|
ConfigError::NotFound(s) => AppError::NotFound(s),
|
||||||
|
ConfigError::DuplicateKey(s) => AppError::Conflict(s),
|
||||||
|
ConfigError::NumberingExhausted(s) => AppError::Internal(s),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ConfigResult<T> = Result<T, ConfigError>;
|
||||||
163
crates/erp-config/src/handler/dictionary_handler.rs
Normal file
163
crates/erp-config/src/handler/dictionary_handler.rs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::{
|
||||||
|
CreateDictionaryReq, DictionaryItemResp, DictionaryResp, UpdateDictionaryReq,
|
||||||
|
};
|
||||||
|
use crate::service::dictionary_service::DictionaryService;
|
||||||
|
|
||||||
|
/// GET /api/v1/dictionaries
|
||||||
|
///
|
||||||
|
/// 分页查询当前租户下的字典列表。
|
||||||
|
/// 每个字典包含其关联的字典项。
|
||||||
|
/// 需要 `dictionary.list` 权限。
|
||||||
|
pub async fn list_dictionaries<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
) -> Result<Json<ApiResponse<PaginatedResponse<DictionaryResp>>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.list")?;
|
||||||
|
|
||||||
|
let (dictionaries, total) =
|
||||||
|
DictionaryService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||||
|
|
||||||
|
let page = pagination.page.unwrap_or(1);
|
||||||
|
let page_size = pagination.limit();
|
||||||
|
let total_pages = (total + page_size - 1) / page_size;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
|
data: dictionaries,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_pages,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/dictionaries
|
||||||
|
///
|
||||||
|
/// 在当前租户下创建新字典。
|
||||||
|
/// 字典编码在租户内必须唯一。
|
||||||
|
/// 需要 `dictionary.create` 权限。
|
||||||
|
pub async fn create_dictionary<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<CreateDictionaryReq>,
|
||||||
|
) -> Result<Json<ApiResponse<DictionaryResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.create")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let dictionary = DictionaryService::create(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req.name,
|
||||||
|
&req.code,
|
||||||
|
&req.description,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(dictionary)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/dictionaries/:id
|
||||||
|
///
|
||||||
|
/// 更新字典的可编辑字段(名称、描述)。
|
||||||
|
/// 编码创建后不可更改。
|
||||||
|
/// 需要 `dictionary.update` 权限。
|
||||||
|
pub async fn update_dictionary<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateDictionaryReq>,
|
||||||
|
) -> Result<Json<ApiResponse<DictionaryResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.update")?;
|
||||||
|
|
||||||
|
let dictionary = DictionaryService::update(
|
||||||
|
id,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req.name,
|
||||||
|
&req.description,
|
||||||
|
&state.db,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(dictionary)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DELETE /api/v1/dictionaries/:id
|
||||||
|
///
|
||||||
|
/// 软删除字典,设置 deleted_at 时间戳。
|
||||||
|
/// 需要 `dictionary.delete` 权限。
|
||||||
|
pub async fn delete_dictionary<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.delete")?;
|
||||||
|
|
||||||
|
DictionaryService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("字典已删除".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v1/dictionaries/items-by-code?code=xxx
|
||||||
|
///
|
||||||
|
/// 根据字典编码查询所有字典项。
|
||||||
|
/// 用于前端下拉框和枚举值查找。
|
||||||
|
/// 需要 `dictionary.list` 权限。
|
||||||
|
pub async fn list_items_by_code<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(query): Query<ItemsByCodeQuery>,
|
||||||
|
) -> Result<Json<ApiResponse<Vec<DictionaryItemResp>>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "dictionary.list")?;
|
||||||
|
|
||||||
|
let items =
|
||||||
|
DictionaryService::list_items_by_code(&query.code, ctx.tenant_id, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(items)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按编码查询字典项的查询参数。
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct ItemsByCodeQuery {
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
101
crates/erp-config/src/handler/language_handler.rs
Normal file
101
crates/erp-config/src/handler/language_handler.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Json, Path, State};
|
||||||
|
use axum::response::Json as JsonResponse;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, Pagination, TenantContext};
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::{LanguageResp, UpdateLanguageReq};
|
||||||
|
use crate::service::setting_service::SettingService;
|
||||||
|
|
||||||
|
/// GET /api/v1/languages
|
||||||
|
///
|
||||||
|
/// 获取当前租户的语言配置列表。
|
||||||
|
/// 查询 scope 为 "platform" 的设置,过滤 key 以 "language." 开头的记录。
|
||||||
|
/// 需要 `language.list` 权限。
|
||||||
|
pub async fn list_languages<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<Vec<LanguageResp>>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "language.list")?;
|
||||||
|
|
||||||
|
let pagination = Pagination {
|
||||||
|
page: Some(1),
|
||||||
|
page_size: Some(100),
|
||||||
|
};
|
||||||
|
|
||||||
|
let (settings, _total) = SettingService::list_by_scope(
|
||||||
|
"platform",
|
||||||
|
&None,
|
||||||
|
ctx.tenant_id,
|
||||||
|
&pagination,
|
||||||
|
&state.db,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let languages: Vec<LanguageResp> = settings
|
||||||
|
.into_iter()
|
||||||
|
.filter(|s| s.setting_key.starts_with("language."))
|
||||||
|
.filter_map(|s| {
|
||||||
|
let code = s.setting_key.strip_prefix("language.")?.to_string();
|
||||||
|
let name = code.clone(); // 默认使用 code 作为名称
|
||||||
|
let is_active = s
|
||||||
|
.setting_value
|
||||||
|
.get("is_active")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
Some(LanguageResp {
|
||||||
|
code,
|
||||||
|
name,
|
||||||
|
is_active,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(languages)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/languages/:code
|
||||||
|
///
|
||||||
|
/// 更新指定语言配置的激活状态。
|
||||||
|
/// 语言配置存储在 settings 表中,key 为 "language.{code}",scope 为 "platform"。
|
||||||
|
/// 需要 `language.update` 权限。
|
||||||
|
pub async fn update_language<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(code): Path<String>,
|
||||||
|
Json(req): Json<UpdateLanguageReq>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<LanguageResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "language.update")?;
|
||||||
|
|
||||||
|
let key = format!("language.{}", code);
|
||||||
|
let value = serde_json::json!({"is_active": req.is_active});
|
||||||
|
|
||||||
|
SettingService::set(
|
||||||
|
&key,
|
||||||
|
"platform",
|
||||||
|
&None,
|
||||||
|
value,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(LanguageResp {
|
||||||
|
code,
|
||||||
|
name: String::new(),
|
||||||
|
is_active: req.is_active,
|
||||||
|
})))
|
||||||
|
}
|
||||||
104
crates/erp-config/src/handler/menu_handler.rs
Normal file
104
crates/erp-config/src/handler/menu_handler.rs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Json, State};
|
||||||
|
use axum::response::Json as JsonResponse;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::{BatchSaveMenusReq, CreateMenuReq, MenuResp};
|
||||||
|
use crate::service::menu_service::MenuService;
|
||||||
|
|
||||||
|
/// GET /api/v1/menus
|
||||||
|
///
|
||||||
|
/// 获取当前租户下当前用户角色可见的菜单树。
|
||||||
|
/// 根据用户关联的角色过滤菜单可见性。
|
||||||
|
/// 需要 `menu.list` 权限。
|
||||||
|
pub async fn get_menus<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<Vec<MenuResp>>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "menu.list")?;
|
||||||
|
|
||||||
|
let role_ids: Vec<Uuid> = ctx
|
||||||
|
.roles
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| Uuid::parse_str(r).ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let menus = MenuService::get_menu_tree(ctx.tenant_id, &role_ids, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(menus)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/menus/batch
|
||||||
|
///
|
||||||
|
/// 批量保存菜单列表。
|
||||||
|
/// 对每个菜单项:有 id 的执行更新,没有 id 的执行创建。
|
||||||
|
/// 需要 `menu.update` 权限。
|
||||||
|
pub async fn batch_save_menus<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<BatchSaveMenusReq>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<()>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "menu.update")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
for item in &req.menus {
|
||||||
|
match item.id {
|
||||||
|
Some(id) => {
|
||||||
|
let update_req = crate::dto::UpdateMenuReq {
|
||||||
|
title: Some(item.title.clone()),
|
||||||
|
path: item.path.clone(),
|
||||||
|
icon: item.icon.clone(),
|
||||||
|
sort_order: item.sort_order,
|
||||||
|
visible: item.visible,
|
||||||
|
permission: item.permission.clone(),
|
||||||
|
role_ids: item.role_ids.clone(),
|
||||||
|
};
|
||||||
|
MenuService::update(id, ctx.tenant_id, ctx.user_id, &update_req, &state.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
let create_req = CreateMenuReq {
|
||||||
|
parent_id: item.parent_id,
|
||||||
|
title: item.title.clone(),
|
||||||
|
path: item.path.clone(),
|
||||||
|
icon: item.icon.clone(),
|
||||||
|
sort_order: item.sort_order,
|
||||||
|
visible: item.visible,
|
||||||
|
menu_type: item.menu_type.clone(),
|
||||||
|
permission: item.permission.clone(),
|
||||||
|
role_ids: item.role_ids.clone(),
|
||||||
|
};
|
||||||
|
MenuService::create(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&create_req,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse {
|
||||||
|
success: true,
|
||||||
|
data: None,
|
||||||
|
message: Some("菜单批量保存成功".to_string()),
|
||||||
|
}))
|
||||||
|
}
|
||||||
6
crates/erp-config/src/handler/mod.rs
Normal file
6
crates/erp-config/src/handler/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod dictionary_handler;
|
||||||
|
pub mod language_handler;
|
||||||
|
pub mod menu_handler;
|
||||||
|
pub mod numbering_handler;
|
||||||
|
pub mod setting_handler;
|
||||||
|
pub mod theme_handler;
|
||||||
119
crates/erp-config/src/handler/numbering_handler.rs
Normal file
119
crates/erp-config/src/handler/numbering_handler.rs
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
use validator::Validate;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::{
|
||||||
|
CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp, UpdateNumberingRuleReq,
|
||||||
|
};
|
||||||
|
use crate::service::numbering_service::NumberingService;
|
||||||
|
|
||||||
|
/// GET /api/v1/numbering-rules
|
||||||
|
///
|
||||||
|
/// 分页查询当前租户下的编号规则列表。
|
||||||
|
/// 需要 `numbering.list` 权限。
|
||||||
|
pub async fn list_numbering_rules<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Query(pagination): Query<Pagination>,
|
||||||
|
) -> Result<Json<ApiResponse<PaginatedResponse<NumberingRuleResp>>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "numbering.list")?;
|
||||||
|
|
||||||
|
let (rules, total) = NumberingService::list(ctx.tenant_id, &pagination, &state.db).await?;
|
||||||
|
|
||||||
|
let page = pagination.page.unwrap_or(1);
|
||||||
|
let page_size = pagination.limit();
|
||||||
|
let total_pages = (total + page_size - 1) / page_size;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(PaginatedResponse {
|
||||||
|
data: rules,
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
page_size,
|
||||||
|
total_pages,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/numbering-rules
|
||||||
|
///
|
||||||
|
/// 创建新的编号规则。
|
||||||
|
/// 规则编码在租户内必须唯一。
|
||||||
|
/// 需要 `numbering.create` 权限。
|
||||||
|
pub async fn create_numbering_rule<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<CreateNumberingRuleReq>,
|
||||||
|
) -> Result<Json<ApiResponse<NumberingRuleResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "numbering.create")?;
|
||||||
|
|
||||||
|
req.validate()
|
||||||
|
.map_err(|e| AppError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let rule = NumberingService::create(
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&req,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(rule)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/numbering-rules/:id
|
||||||
|
///
|
||||||
|
/// 更新编号规则的可编辑字段。
|
||||||
|
/// 需要 `numbering.update` 权限。
|
||||||
|
pub async fn update_numbering_rule<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
Json(req): Json<UpdateNumberingRuleReq>,
|
||||||
|
) -> Result<Json<ApiResponse<NumberingRuleResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "numbering.update")?;
|
||||||
|
|
||||||
|
let rule =
|
||||||
|
NumberingService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(rule)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /api/v1/numbering-rules/:id/generate
|
||||||
|
///
|
||||||
|
/// 根据编号规则生成新的编号。
|
||||||
|
/// 使用 PostgreSQL advisory lock 保证并发安全。
|
||||||
|
/// 需要 `numbering.generate` 权限。
|
||||||
|
pub async fn generate_number<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> Result<Json<ApiResponse<GenerateNumberResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "numbering.generate")?;
|
||||||
|
|
||||||
|
let result = NumberingService::generate_number(id, ctx.tenant_id, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
76
crates/erp-config/src/handler/setting_handler.rs
Normal file
76
crates/erp-config/src/handler/setting_handler.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Path, Query, State};
|
||||||
|
use axum::response::Json;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::{SettingResp, UpdateSettingReq};
|
||||||
|
use crate::service::setting_service::SettingService;
|
||||||
|
|
||||||
|
/// GET /api/v1/settings/:key?scope=tenant&scope_id=xxx
|
||||||
|
///
|
||||||
|
/// 获取设置值,支持分层回退查找。
|
||||||
|
/// 解析顺序:精确匹配 -> 按作用域层级向上回退。
|
||||||
|
/// 需要 `setting.read` 权限。
|
||||||
|
pub async fn get_setting<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
Query(query): Query<SettingQuery>,
|
||||||
|
) -> Result<Json<ApiResponse<SettingResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "setting.read")?;
|
||||||
|
|
||||||
|
let scope = query.scope.unwrap_or_else(|| "tenant".to_string());
|
||||||
|
|
||||||
|
let setting =
|
||||||
|
SettingService::get(&key, &scope, &query.scope_id, ctx.tenant_id, &state.db).await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(setting)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/settings/:key
|
||||||
|
///
|
||||||
|
/// 创建或更新设置值。
|
||||||
|
/// 如果相同 (scope, scope_id, key) 的记录存在则更新,否则插入。
|
||||||
|
/// 需要 `setting.update` 权限。
|
||||||
|
pub async fn update_setting<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(key): Path<String>,
|
||||||
|
Json(req): Json<UpdateSettingReq>,
|
||||||
|
) -> Result<Json<ApiResponse<SettingResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "setting.update")?;
|
||||||
|
|
||||||
|
let setting = SettingService::set(
|
||||||
|
&key,
|
||||||
|
"tenant",
|
||||||
|
&None,
|
||||||
|
req.setting_value,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(ApiResponse::ok(setting)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 设置查询参数。
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
pub struct SettingQuery {
|
||||||
|
pub scope: Option<String>,
|
||||||
|
pub scope_id: Option<Uuid>,
|
||||||
|
}
|
||||||
69
crates/erp-config/src/handler/theme_handler.rs
Normal file
69
crates/erp-config/src/handler/theme_handler.rs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::extract::{FromRef, Json, State};
|
||||||
|
use axum::response::Json as JsonResponse;
|
||||||
|
|
||||||
|
use erp_core::error::AppError;
|
||||||
|
use erp_core::rbac::require_permission;
|
||||||
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
|
||||||
|
use crate::config_state::ConfigState;
|
||||||
|
use crate::dto::ThemeResp;
|
||||||
|
use crate::service::setting_service::SettingService;
|
||||||
|
|
||||||
|
/// GET /api/v1/theme
|
||||||
|
///
|
||||||
|
/// 获取当前租户的主题配置。
|
||||||
|
/// 主题配置存储在 settings 表中,key 为 "theme",scope 为 "tenant"。
|
||||||
|
/// 需要 `theme.read` 权限。
|
||||||
|
pub async fn get_theme<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<ThemeResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "theme.read")?;
|
||||||
|
|
||||||
|
let setting =
|
||||||
|
SettingService::get("theme", "tenant", &None, ctx.tenant_id, &state.db).await?;
|
||||||
|
|
||||||
|
let theme: ThemeResp = serde_json::from_value(setting.setting_value)
|
||||||
|
.map_err(|e| AppError::Validation(format!("主题配置解析失败: {e}")))?;
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(theme)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PUT /api/v1/theme
|
||||||
|
///
|
||||||
|
/// 更新当前租户的主题配置。
|
||||||
|
/// 将主题配置序列化为 JSON 存储到 settings 表。
|
||||||
|
/// 需要 `theme.update` 权限。
|
||||||
|
pub async fn update_theme<S>(
|
||||||
|
State(state): State<ConfigState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Json(req): Json<ThemeResp>,
|
||||||
|
) -> Result<JsonResponse<ApiResponse<ThemeResp>>, AppError>
|
||||||
|
where
|
||||||
|
ConfigState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "theme.update")?;
|
||||||
|
|
||||||
|
let value = serde_json::to_value(&req)
|
||||||
|
.map_err(|e| AppError::Validation(format!("主题配置序列化失败: {e}")))?;
|
||||||
|
|
||||||
|
SettingService::set(
|
||||||
|
"theme",
|
||||||
|
"tenant",
|
||||||
|
&None,
|
||||||
|
value,
|
||||||
|
ctx.tenant_id,
|
||||||
|
ctx.user_id,
|
||||||
|
&state.db,
|
||||||
|
&state.event_bus,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(JsonResponse(ApiResponse::ok(req)))
|
||||||
|
}
|
||||||
@@ -1 +1,10 @@
|
|||||||
// erp-config: 系统配置模块 (Phase 3)
|
pub mod config_state;
|
||||||
|
pub mod dto;
|
||||||
|
pub mod entity;
|
||||||
|
pub mod error;
|
||||||
|
pub mod handler;
|
||||||
|
pub mod module;
|
||||||
|
pub mod service;
|
||||||
|
|
||||||
|
pub use config_state::ConfigState;
|
||||||
|
pub use module::ConfigModule;
|
||||||
|
|||||||
125
crates/erp-config/src/module.rs
Normal file
125
crates/erp-config/src/module.rs
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
use axum::Router;
|
||||||
|
use axum::routing::{get, post, put};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use erp_core::error::AppResult;
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::module::ErpModule;
|
||||||
|
|
||||||
|
use crate::handler::{
|
||||||
|
dictionary_handler, language_handler, menu_handler, numbering_handler, setting_handler,
|
||||||
|
theme_handler,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Config module implementing the `ErpModule` trait.
|
||||||
|
///
|
||||||
|
/// Manages system configuration: dictionaries, menus, settings,
|
||||||
|
/// numbering rules, languages, and themes.
|
||||||
|
pub struct ConfigModule;
|
||||||
|
|
||||||
|
impl ConfigModule {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build protected (authenticated) routes for the config module.
|
||||||
|
pub fn protected_routes<S>() -> Router<S>
|
||||||
|
where
|
||||||
|
crate::config_state::ConfigState: axum::extract::FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
Router::new()
|
||||||
|
// Dictionary routes
|
||||||
|
.route(
|
||||||
|
"/config/dictionaries",
|
||||||
|
get(dictionary_handler::list_dictionaries)
|
||||||
|
.post(dictionary_handler::create_dictionary),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/dictionaries/{id}",
|
||||||
|
put(dictionary_handler::update_dictionary)
|
||||||
|
.delete(dictionary_handler::delete_dictionary),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/dictionaries/items",
|
||||||
|
get(dictionary_handler::list_items_by_code),
|
||||||
|
)
|
||||||
|
// Menu routes
|
||||||
|
.route(
|
||||||
|
"/config/menus",
|
||||||
|
get(menu_handler::get_menus).put(menu_handler::batch_save_menus),
|
||||||
|
)
|
||||||
|
// Setting routes
|
||||||
|
.route(
|
||||||
|
"/config/settings/{key}",
|
||||||
|
get(setting_handler::get_setting).put(setting_handler::update_setting),
|
||||||
|
)
|
||||||
|
// Numbering rule routes
|
||||||
|
.route(
|
||||||
|
"/config/numbering-rules",
|
||||||
|
get(numbering_handler::list_numbering_rules)
|
||||||
|
.post(numbering_handler::create_numbering_rule),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/numbering-rules/{id}",
|
||||||
|
put(numbering_handler::update_numbering_rule),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/numbering-rules/{id}/generate",
|
||||||
|
post(numbering_handler::generate_number),
|
||||||
|
)
|
||||||
|
// Theme routes
|
||||||
|
.route(
|
||||||
|
"/config/themes",
|
||||||
|
get(theme_handler::get_theme).put(theme_handler::update_theme),
|
||||||
|
)
|
||||||
|
// Language routes
|
||||||
|
.route(
|
||||||
|
"/config/languages",
|
||||||
|
get(language_handler::list_languages),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/config/languages/{code}",
|
||||||
|
put(language_handler::update_language),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ConfigModule {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ErpModule for ConfigModule {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"config"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version(&self) -> &str {
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dependencies(&self) -> Vec<&str> {
|
||||||
|
vec!["auth"]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_routes(&self, router: Router) -> Router {
|
||||||
|
router
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_event_handlers(&self, _bus: &EventBus) {}
|
||||||
|
|
||||||
|
async fn on_tenant_created(&self, _tenant_id: Uuid) -> AppResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_tenant_deleted(&self, _tenant_id: Uuid) -> AppResult<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any(&self) -> &dyn std::any::Any {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
416
crates/erp-config/src/service/dictionary_service.rs
Normal file
416
crates/erp-config/src/service/dictionary_service.rs
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{DictionaryItemResp, DictionaryResp};
|
||||||
|
use crate::entity::{dictionary, dictionary_item};
|
||||||
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
|
/// Dictionary CRUD service — manage dictionaries and their items within a tenant.
|
||||||
|
///
|
||||||
|
/// Dictionaries provide enumerated value sets (e.g. status codes, categories)
|
||||||
|
/// that can be referenced throughout the system by their unique `code`.
|
||||||
|
pub struct DictionaryService;
|
||||||
|
|
||||||
|
impl DictionaryService {
|
||||||
|
/// List dictionaries within a tenant with pagination.
|
||||||
|
///
|
||||||
|
/// Each dictionary includes its associated items.
|
||||||
|
/// Returns `(dictionaries, total_count)`.
|
||||||
|
pub async fn list(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
pagination: &Pagination,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<(Vec<DictionaryResp>, u64)> {
|
||||||
|
let paginator = dictionary::Entity::find()
|
||||||
|
.filter(dictionary::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dictionary::Column::DeletedAt.is_null())
|
||||||
|
.paginate(db, pagination.limit());
|
||||||
|
|
||||||
|
let total = paginator
|
||||||
|
.num_items()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
||||||
|
let models = paginator
|
||||||
|
.fetch_page(page_index)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let mut resps = Vec::with_capacity(models.len());
|
||||||
|
for m in &models {
|
||||||
|
let items = Self::fetch_items(m.id, tenant_id, db).await?;
|
||||||
|
resps.push(DictionaryResp {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name.clone(),
|
||||||
|
code: m.code.clone(),
|
||||||
|
description: m.description.clone(),
|
||||||
|
items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((resps, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a single dictionary by ID, scoped to the given tenant.
|
||||||
|
///
|
||||||
|
/// Includes all associated items.
|
||||||
|
pub async fn get_by_id(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<DictionaryResp> {
|
||||||
|
let model = dictionary::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
||||||
|
|
||||||
|
let items = Self::fetch_items(model.id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
Ok(DictionaryResp {
|
||||||
|
id: model.id,
|
||||||
|
name: model.name.clone(),
|
||||||
|
code: model.code.clone(),
|
||||||
|
description: model.description.clone(),
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new dictionary within the current tenant.
|
||||||
|
///
|
||||||
|
/// Validates code uniqueness, then inserts the record and publishes
|
||||||
|
/// a `dictionary.created` domain event.
|
||||||
|
pub async fn create(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
name: &str,
|
||||||
|
code: &str,
|
||||||
|
description: &Option<String>,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<DictionaryResp> {
|
||||||
|
// Check code uniqueness within tenant
|
||||||
|
let existing = dictionary::Entity::find()
|
||||||
|
.filter(dictionary::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dictionary::Column::Code.eq(code))
|
||||||
|
.filter(dictionary::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(ConfigError::Validation("字典编码已存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let model = dictionary::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
code: Set(code.to_string()),
|
||||||
|
description: Set(description.clone()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"dictionary.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "dictionary_id": id, "code": code }),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(DictionaryResp {
|
||||||
|
id,
|
||||||
|
name: name.to_string(),
|
||||||
|
code: code.to_string(),
|
||||||
|
description: description.clone(),
|
||||||
|
items: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update editable dictionary fields (name and description).
|
||||||
|
///
|
||||||
|
/// Code cannot be changed after creation.
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
name: &Option<String>,
|
||||||
|
description: &Option<String>,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<DictionaryResp> {
|
||||||
|
let model = dictionary::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
||||||
|
|
||||||
|
let mut active: dictionary::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(n) = name {
|
||||||
|
active.name = Set(n.clone());
|
||||||
|
}
|
||||||
|
if let Some(d) = description {
|
||||||
|
active.description = Set(Some(d.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let items = Self::fetch_items(updated.id, tenant_id, db).await?;
|
||||||
|
|
||||||
|
Ok(DictionaryResp {
|
||||||
|
id: updated.id,
|
||||||
|
name: updated.name.clone(),
|
||||||
|
code: updated.code.clone(),
|
||||||
|
description: updated.description.clone(),
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a dictionary by setting the `deleted_at` timestamp.
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
let model = dictionary::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
||||||
|
|
||||||
|
let mut active: dictionary::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"dictionary.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "dictionary_id": id }),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a new item to a dictionary.
|
||||||
|
///
|
||||||
|
/// Validates that the item `value` is unique within the dictionary.
|
||||||
|
pub async fn add_item(
|
||||||
|
dictionary_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
label: &str,
|
||||||
|
value: &str,
|
||||||
|
sort_order: i32,
|
||||||
|
color: &Option<String>,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<DictionaryItemResp> {
|
||||||
|
// Verify the dictionary exists and belongs to this tenant
|
||||||
|
let _dict = dictionary::Entity::find_by_id(dictionary_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?;
|
||||||
|
|
||||||
|
// Check value uniqueness within dictionary
|
||||||
|
let existing = dictionary_item::Entity::find()
|
||||||
|
.filter(dictionary_item::Column::DictionaryId.eq(dictionary_id))
|
||||||
|
.filter(dictionary_item::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dictionary_item::Column::Value.eq(value))
|
||||||
|
.filter(dictionary_item::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(ConfigError::Validation("字典项值已存在".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let model = dictionary_item::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
dictionary_id: Set(dictionary_id),
|
||||||
|
label: Set(label.to_string()),
|
||||||
|
value: Set(value.to_string()),
|
||||||
|
sort_order: Set(sort_order),
|
||||||
|
color: Set(color.clone()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(DictionaryItemResp {
|
||||||
|
id,
|
||||||
|
dictionary_id,
|
||||||
|
label: label.to_string(),
|
||||||
|
value: value.to_string(),
|
||||||
|
sort_order,
|
||||||
|
color: color.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update editable dictionary item fields (label, value, sort_order, color).
|
||||||
|
pub async fn update_item(
|
||||||
|
item_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
label: &Option<String>,
|
||||||
|
value: &Option<String>,
|
||||||
|
sort_order: &Option<i32>,
|
||||||
|
color: &Option<String>,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<DictionaryItemResp> {
|
||||||
|
let model = dictionary_item::Entity::find_by_id(item_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
|
||||||
|
|
||||||
|
let mut active: dictionary_item::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(l) = label {
|
||||||
|
active.label = Set(l.clone());
|
||||||
|
}
|
||||||
|
if let Some(v) = value {
|
||||||
|
active.value = Set(v.clone());
|
||||||
|
}
|
||||||
|
if let Some(s) = sort_order {
|
||||||
|
active.sort_order = Set(*s);
|
||||||
|
}
|
||||||
|
if let Some(c) = color {
|
||||||
|
active.color = Set(Some(c.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(DictionaryItemResp {
|
||||||
|
id: updated.id,
|
||||||
|
dictionary_id: updated.dictionary_id,
|
||||||
|
label: updated.label.clone(),
|
||||||
|
value: updated.value.clone(),
|
||||||
|
sort_order: updated.sort_order,
|
||||||
|
color: updated.color.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a dictionary item by setting the `deleted_at` timestamp.
|
||||||
|
pub async fn delete_item(
|
||||||
|
item_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
let model = dictionary_item::Entity::find_by_id(item_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?;
|
||||||
|
|
||||||
|
let mut active: dictionary_item::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up a dictionary by its `code` and return all items.
|
||||||
|
///
|
||||||
|
/// Useful for frontend dropdowns and enum-like lookups.
|
||||||
|
pub async fn list_items_by_code(
|
||||||
|
code: &str,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<Vec<DictionaryItemResp>> {
|
||||||
|
let dict = dictionary::Entity::find()
|
||||||
|
.filter(dictionary::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dictionary::Column::Code.eq(code))
|
||||||
|
.filter(dictionary::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("字典编码 '{}' 不存在", code)))?;
|
||||||
|
|
||||||
|
Self::fetch_items(dict.id, tenant_id, db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 内部辅助方法 ----
|
||||||
|
|
||||||
|
/// Fetch all non-deleted items for a given dictionary.
|
||||||
|
async fn fetch_items(
|
||||||
|
dictionary_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<Vec<DictionaryItemResp>> {
|
||||||
|
let items = dictionary_item::Entity::find()
|
||||||
|
.filter(dictionary_item::Column::DictionaryId.eq(dictionary_id))
|
||||||
|
.filter(dictionary_item::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(dictionary_item::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(items
|
||||||
|
.iter()
|
||||||
|
.map(|i| DictionaryItemResp {
|
||||||
|
id: i.id,
|
||||||
|
dictionary_id: i.dictionary_id,
|
||||||
|
label: i.label.clone(),
|
||||||
|
value: i.value.clone(),
|
||||||
|
sort_order: i.sort_order,
|
||||||
|
color: i.color.clone(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
355
crates/erp-config/src/service/menu_service.rs
Normal file
355
crates/erp-config/src/service/menu_service.rs
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{CreateMenuReq, MenuResp};
|
||||||
|
use crate::entity::{menu, menu_role};
|
||||||
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
|
||||||
|
/// 菜单 CRUD 服务 -- 创建、查询(树形/平铺)、更新、软删除菜单,
|
||||||
|
/// 以及管理菜单-角色关联。
|
||||||
|
pub struct MenuService;
|
||||||
|
|
||||||
|
impl MenuService {
|
||||||
|
/// 获取当前租户下指定角色可见的菜单树。
|
||||||
|
///
|
||||||
|
/// 如果 `role_ids` 非空,仅返回这些角色关联的菜单;
|
||||||
|
/// 否则返回租户全部菜单。结果按 `sort_order` 排列并组装为树形结构。
|
||||||
|
pub async fn get_menu_tree(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
role_ids: &[Uuid],
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<Vec<MenuResp>> {
|
||||||
|
// 1. 查询租户下所有未删除的菜单,按 sort_order 排序
|
||||||
|
let all_menus = menu::Entity::find()
|
||||||
|
.filter(menu::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(menu::Column::DeletedAt.is_null())
|
||||||
|
.order_by_asc(menu::Column::SortOrder)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 2. 如果 role_ids 非空,通过 menu_roles 表过滤
|
||||||
|
let visible_menu_ids: Option<Vec<Uuid>> = if !role_ids.is_empty() {
|
||||||
|
let mr_rows = menu_role::Entity::find()
|
||||||
|
.filter(menu_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(menu_role::Column::RoleId.is_in(role_ids.iter().copied()))
|
||||||
|
.filter(menu_role::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let ids: Vec<Uuid> = mr_rows.iter().map(|mr| mr.menu_id).collect();
|
||||||
|
if ids.is_empty() {
|
||||||
|
return Ok(vec![]);
|
||||||
|
}
|
||||||
|
Some(ids)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. 按 parent_id 分组构建 HashMap
|
||||||
|
let filtered: Vec<&menu::Model> = match &visible_menu_ids {
|
||||||
|
Some(ids) => all_menus
|
||||||
|
.iter()
|
||||||
|
.filter(|m| ids.contains(&m.id))
|
||||||
|
.collect(),
|
||||||
|
None => all_menus.iter().collect(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut children_map: HashMap<Option<Uuid>, Vec<&menu::Model>> = HashMap::new();
|
||||||
|
for m in &filtered {
|
||||||
|
children_map
|
||||||
|
.entry(m.parent_id)
|
||||||
|
.or_default()
|
||||||
|
.push(*m);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 递归构建树形结构(从 parent_id == None 的根节点开始)
|
||||||
|
let roots = children_map.get(&None).cloned().unwrap_or_default();
|
||||||
|
let tree = Self::build_tree(&roots, &children_map);
|
||||||
|
|
||||||
|
Ok(tree)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取当前租户下所有菜单的平铺列表(无角色过滤)。
|
||||||
|
pub async fn get_flat_list(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<Vec<MenuResp>> {
|
||||||
|
let menus = menu::Entity::find()
|
||||||
|
.filter(menu::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(menu::Column::DeletedAt.is_null())
|
||||||
|
.order_by_asc(menu::Column::SortOrder)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(menus
|
||||||
|
.iter()
|
||||||
|
.map(|m| MenuResp {
|
||||||
|
id: m.id,
|
||||||
|
parent_id: m.parent_id,
|
||||||
|
title: m.title.clone(),
|
||||||
|
path: m.path.clone(),
|
||||||
|
icon: m.icon.clone(),
|
||||||
|
sort_order: m.sort_order,
|
||||||
|
visible: m.visible,
|
||||||
|
menu_type: m.menu_type.clone(),
|
||||||
|
permission: m.permission.clone(),
|
||||||
|
children: vec![],
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建菜单并可选地关联角色。
|
||||||
|
pub async fn create(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &CreateMenuReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<MenuResp> {
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
|
||||||
|
let model = menu::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
parent_id: Set(req.parent_id),
|
||||||
|
title: Set(req.title.clone()),
|
||||||
|
path: Set(req.path.clone()),
|
||||||
|
icon: Set(req.icon.clone()),
|
||||||
|
sort_order: Set(req.sort_order.unwrap_or(0)),
|
||||||
|
visible: Set(req.visible.unwrap_or(true)),
|
||||||
|
menu_type: Set(req.menu_type.clone().unwrap_or_else(|| "menu".to_string())),
|
||||||
|
permission: Set(req.permission.clone()),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 关联角色(如果提供了 role_ids)
|
||||||
|
if let Some(role_ids) = &req.role_ids {
|
||||||
|
if !role_ids.is_empty() {
|
||||||
|
Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"menu.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "menu_id": id, "title": req.title }),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(MenuResp {
|
||||||
|
id,
|
||||||
|
parent_id: req.parent_id,
|
||||||
|
title: req.title.clone(),
|
||||||
|
path: req.path.clone(),
|
||||||
|
icon: req.icon.clone(),
|
||||||
|
sort_order: req.sort_order.unwrap_or(0),
|
||||||
|
visible: req.visible.unwrap_or(true),
|
||||||
|
menu_type: req.menu_type.clone().unwrap_or_else(|| "menu".to_string()),
|
||||||
|
permission: req.permission.clone(),
|
||||||
|
children: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新菜单字段,并可选地重新关联角色。
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &crate::dto::UpdateMenuReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<MenuResp> {
|
||||||
|
let model = menu::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?;
|
||||||
|
|
||||||
|
let mut active: menu::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(title) = &req.title {
|
||||||
|
active.title = Set(title.clone());
|
||||||
|
}
|
||||||
|
if let Some(path) = &req.path {
|
||||||
|
active.path = Set(Some(path.clone()));
|
||||||
|
}
|
||||||
|
if let Some(icon) = &req.icon {
|
||||||
|
active.icon = Set(Some(icon.clone()));
|
||||||
|
}
|
||||||
|
if let Some(sort_order) = req.sort_order {
|
||||||
|
active.sort_order = Set(sort_order);
|
||||||
|
}
|
||||||
|
if let Some(visible) = req.visible {
|
||||||
|
active.visible = Set(visible);
|
||||||
|
}
|
||||||
|
if let Some(permission) = &req.permission {
|
||||||
|
active.permission = Set(Some(permission.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 如果提供了 role_ids,重新关联角色
|
||||||
|
if let Some(role_ids) = &req.role_ids {
|
||||||
|
Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(MenuResp {
|
||||||
|
id: updated.id,
|
||||||
|
parent_id: updated.parent_id,
|
||||||
|
title: updated.title.clone(),
|
||||||
|
path: updated.path.clone(),
|
||||||
|
icon: updated.icon.clone(),
|
||||||
|
sort_order: updated.sort_order,
|
||||||
|
visible: updated.visible,
|
||||||
|
menu_type: updated.menu_type.clone(),
|
||||||
|
permission: updated.permission.clone(),
|
||||||
|
children: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 软删除菜单。
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
let model = menu::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?;
|
||||||
|
|
||||||
|
let mut active: menu::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"menu.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "menu_id": id }),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 替换菜单的角色关联。
|
||||||
|
///
|
||||||
|
/// 软删除现有关联行,然后插入新关联(参考 RoleService::assign_permissions 模式)。
|
||||||
|
pub async fn assign_roles(
|
||||||
|
menu_id: Uuid,
|
||||||
|
role_ids: &[Uuid],
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
// 验证菜单存在且属于当前租户
|
||||||
|
let _menu = menu::Entity::find_by_id(menu_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {menu_id}")))?;
|
||||||
|
|
||||||
|
// 软删除现有关联
|
||||||
|
let existing = menu_role::Entity::find()
|
||||||
|
.filter(menu_role::Column::MenuId.eq(menu_id))
|
||||||
|
.filter(menu_role::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(menu_role::Column::DeletedAt.is_null())
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
for mr in existing {
|
||||||
|
let mut active: menu_role::ActiveModel = mr.into();
|
||||||
|
active.deleted_at = Set(Some(now));
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 插入新关联
|
||||||
|
for role_id in role_ids {
|
||||||
|
let mr = menu_role::ActiveModel {
|
||||||
|
id: Set(Uuid::now_v7()),
|
||||||
|
menu_id: Set(menu_id),
|
||||||
|
role_id: Set(*role_id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
mr.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 递归构建菜单树。
|
||||||
|
fn build_tree<'a>(
|
||||||
|
nodes: &[&'a menu::Model],
|
||||||
|
children_map: &HashMap<Option<Uuid>, Vec<&'a menu::Model>>,
|
||||||
|
) -> Vec<MenuResp> {
|
||||||
|
nodes
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
let children = children_map
|
||||||
|
.get(&Some(m.id))
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
MenuResp {
|
||||||
|
id: m.id,
|
||||||
|
parent_id: m.parent_id,
|
||||||
|
title: m.title.clone(),
|
||||||
|
path: m.path.clone(),
|
||||||
|
icon: m.icon.clone(),
|
||||||
|
sort_order: m.sort_order,
|
||||||
|
visible: m.visible,
|
||||||
|
menu_type: m.menu_type.clone(),
|
||||||
|
permission: m.permission.clone(),
|
||||||
|
children: Self::build_tree(&children, children_map),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
4
crates/erp-config/src/service/mod.rs
Normal file
4
crates/erp-config/src/service/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod dictionary_service;
|
||||||
|
pub mod menu_service;
|
||||||
|
pub mod numbering_service;
|
||||||
|
pub mod setting_service;
|
||||||
378
crates/erp-config/src/service/numbering_service.rs
Normal file
378
crates/erp-config/src/service/numbering_service.rs
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
use chrono::{Datelike, NaiveDate, Utc};
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||||
|
Statement, ConnectionTrait, DatabaseBackend, TransactionTrait,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::{CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp};
|
||||||
|
use crate::entity::numbering_rule;
|
||||||
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
|
/// 编号规则 CRUD 服务 -- 创建、查询、更新、软删除编号规则,
|
||||||
|
/// 以及线程安全地生成编号序列。
|
||||||
|
pub struct NumberingService;
|
||||||
|
|
||||||
|
impl NumberingService {
|
||||||
|
/// 分页查询编号规则列表。
|
||||||
|
pub async fn list(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
pagination: &Pagination,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<(Vec<NumberingRuleResp>, u64)> {
|
||||||
|
let paginator = numbering_rule::Entity::find()
|
||||||
|
.filter(numbering_rule::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(numbering_rule::Column::DeletedAt.is_null())
|
||||||
|
.paginate(db, pagination.limit());
|
||||||
|
|
||||||
|
let total = paginator
|
||||||
|
.num_items()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
||||||
|
let models = paginator
|
||||||
|
.fetch_page(page_index)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let resps: Vec<NumberingRuleResp> = models
|
||||||
|
.iter()
|
||||||
|
.map(|m| Self::model_to_resp(m))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok((resps, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 创建编号规则。
|
||||||
|
///
|
||||||
|
/// 检查 code 在租户内唯一后插入。
|
||||||
|
pub async fn create(
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &CreateNumberingRuleReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<NumberingRuleResp> {
|
||||||
|
// 检查 code 唯一性
|
||||||
|
let existing = numbering_rule::Entity::find()
|
||||||
|
.filter(numbering_rule::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(numbering_rule::Column::Code.eq(&req.code))
|
||||||
|
.filter(numbering_rule::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
if existing.is_some() {
|
||||||
|
return Err(ConfigError::DuplicateKey(format!(
|
||||||
|
"编号规则编码已存在: {}",
|
||||||
|
req.code
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let seq_start = req.seq_start.unwrap_or(1);
|
||||||
|
|
||||||
|
let model = numbering_rule::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
name: Set(req.name.clone()),
|
||||||
|
code: Set(req.code.clone()),
|
||||||
|
prefix: Set(req.prefix.clone().unwrap_or_default()),
|
||||||
|
date_format: Set(req.date_format.clone()),
|
||||||
|
seq_length: Set(req.seq_length.unwrap_or(4)),
|
||||||
|
seq_start: Set(seq_start),
|
||||||
|
seq_current: Set(seq_start as i64),
|
||||||
|
separator: Set(req.separator.clone().unwrap_or_else(|| "-".to_string())),
|
||||||
|
reset_cycle: Set(req.reset_cycle.clone().unwrap_or_else(|| "never".to_string())),
|
||||||
|
last_reset_date: Set(Some(Utc::now().date_naive())),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"numbering_rule.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "rule_id": id, "code": req.code }),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(NumberingRuleResp {
|
||||||
|
id,
|
||||||
|
name: req.name.clone(),
|
||||||
|
code: req.code.clone(),
|
||||||
|
prefix: req.prefix.clone().unwrap_or_default(),
|
||||||
|
date_format: req.date_format.clone(),
|
||||||
|
seq_length: req.seq_length.unwrap_or(4),
|
||||||
|
seq_start,
|
||||||
|
seq_current: seq_start as i64,
|
||||||
|
separator: req.separator.clone().unwrap_or_else(|| "-".to_string()),
|
||||||
|
reset_cycle: req.reset_cycle.clone().unwrap_or_else(|| "never".to_string()),
|
||||||
|
last_reset_date: Some(Utc::now().date_naive().to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 更新编号规则的可编辑字段。
|
||||||
|
pub async fn update(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
req: &crate::dto::UpdateNumberingRuleReq,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<NumberingRuleResp> {
|
||||||
|
let model = numbering_rule::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
|
||||||
|
|
||||||
|
let mut active: numbering_rule::ActiveModel = model.into();
|
||||||
|
|
||||||
|
if let Some(name) = &req.name {
|
||||||
|
active.name = Set(name.clone());
|
||||||
|
}
|
||||||
|
if let Some(prefix) = &req.prefix {
|
||||||
|
active.prefix = Set(prefix.clone());
|
||||||
|
}
|
||||||
|
if let Some(date_format) = &req.date_format {
|
||||||
|
active.date_format = Set(Some(date_format.clone()));
|
||||||
|
}
|
||||||
|
if let Some(seq_length) = req.seq_length {
|
||||||
|
active.seq_length = Set(seq_length);
|
||||||
|
}
|
||||||
|
if let Some(separator) = &req.separator {
|
||||||
|
active.separator = Set(separator.clone());
|
||||||
|
}
|
||||||
|
if let Some(reset_cycle) = &req.reset_cycle {
|
||||||
|
active.reset_cycle = Set(reset_cycle.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Self::model_to_resp(&updated))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 软删除编号规则。
|
||||||
|
pub async fn delete(
|
||||||
|
id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
let model = numbering_rule::Entity::find_by_id(id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?;
|
||||||
|
|
||||||
|
let mut active: numbering_rule::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"numbering_rule.deleted",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({ "rule_id": id }),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 线程安全地生成编号。
|
||||||
|
///
|
||||||
|
/// 使用 PostgreSQL advisory lock 保证并发安全:
|
||||||
|
/// 1. 获取 pg_advisory_xact_lock
|
||||||
|
/// 2. 在事务内读取规则、检查重置周期、递增序列、更新数据库
|
||||||
|
/// 3. 拼接编号字符串返回
|
||||||
|
pub async fn generate_number(
|
||||||
|
rule_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<GenerateNumberResp> {
|
||||||
|
// 先读取规则获取 code(用于 advisory lock)
|
||||||
|
let rule = numbering_rule::Entity::find_by_id(rule_id)
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?;
|
||||||
|
|
||||||
|
let rule_code = rule.code.clone();
|
||||||
|
let tenant_id_str = tenant_id.to_string();
|
||||||
|
|
||||||
|
// 获取 PostgreSQL advisory lock(事务级别,事务结束自动释放)
|
||||||
|
db.execute(Statement::from_sql_and_values(
|
||||||
|
DatabaseBackend::Postgres,
|
||||||
|
"SELECT pg_advisory_xact_lock(abs(hashtext($1)), abs(hashtext($2))::int)",
|
||||||
|
[
|
||||||
|
rule_code.into(),
|
||||||
|
tenant_id_str.into(),
|
||||||
|
],
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(format!("获取编号锁失败: {e}")))?;
|
||||||
|
|
||||||
|
// 在事务内执行序列递增和更新
|
||||||
|
let number = db
|
||||||
|
.transaction(|txn| {
|
||||||
|
Box::pin(async move {
|
||||||
|
Self::generate_number_in_txn(rule_id, tenant_id, txn).await
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(GenerateNumberResp { number })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 事务内执行编号生成逻辑。
|
||||||
|
///
|
||||||
|
/// 检查重置周期,必要时重置序列,然后递增并拼接编号。
|
||||||
|
async fn generate_number_in_txn<C>(
|
||||||
|
rule_id: Uuid,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
txn: &C,
|
||||||
|
) -> ConfigResult<String>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
let rule = numbering_rule::Entity::find_by_id(rule_id)
|
||||||
|
.one(txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none())
|
||||||
|
.ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?;
|
||||||
|
|
||||||
|
let today = Utc::now().date_naive();
|
||||||
|
let mut seq_current = rule.seq_current;
|
||||||
|
|
||||||
|
// 检查是否需要重置序列
|
||||||
|
seq_current = Self::maybe_reset_sequence(
|
||||||
|
seq_current,
|
||||||
|
rule.seq_start as i64,
|
||||||
|
&rule.reset_cycle,
|
||||||
|
rule.last_reset_date,
|
||||||
|
today,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 递增序列
|
||||||
|
let next_seq = seq_current + 1;
|
||||||
|
|
||||||
|
// 检查序列是否超出 seq_length 能表示的最大值
|
||||||
|
let max_val = 10i64.pow(rule.seq_length as u32) - 1;
|
||||||
|
if next_seq > max_val {
|
||||||
|
return Err(ConfigError::NumberingExhausted(format!(
|
||||||
|
"编号序列已耗尽,当前序列号 {next_seq} 超出长度 {} 的最大值",
|
||||||
|
rule.seq_length
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新数据库中的 seq_current 和 last_reset_date
|
||||||
|
let mut active: numbering_rule::ActiveModel = rule.clone().into();
|
||||||
|
active.seq_current = Set(next_seq);
|
||||||
|
active.last_reset_date = Set(Some(today));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active
|
||||||
|
.update(txn)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
// 拼接编号字符串: {prefix}{separator}{date_part}{separator}{seq_padded}
|
||||||
|
let separator = &rule.separator;
|
||||||
|
let mut parts = vec![rule.prefix.clone()];
|
||||||
|
|
||||||
|
// 日期部分(如果配置了 date_format)
|
||||||
|
if let Some(date_fmt) = &rule.date_format {
|
||||||
|
let date_part = Utc::now().format(date_fmt).to_string();
|
||||||
|
parts.push(date_part);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 序列号补零
|
||||||
|
let seq_padded = format!("{:0>width$}", seq_current, width = rule.seq_length as usize);
|
||||||
|
parts.push(seq_padded);
|
||||||
|
|
||||||
|
let number = parts.join(separator);
|
||||||
|
|
||||||
|
Ok(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 根据重置周期判断是否需要重置序列号。
|
||||||
|
///
|
||||||
|
/// 如果需要重置,返回 `seq_start`;否则返回原值。
|
||||||
|
fn maybe_reset_sequence(
|
||||||
|
seq_current: i64,
|
||||||
|
seq_start: i64,
|
||||||
|
reset_cycle: &str,
|
||||||
|
last_reset_date: Option<NaiveDate>,
|
||||||
|
today: NaiveDate,
|
||||||
|
) -> i64 {
|
||||||
|
let last_reset = match last_reset_date {
|
||||||
|
Some(d) => d,
|
||||||
|
None => return seq_start, // 从未重置过,使用 seq_start
|
||||||
|
};
|
||||||
|
|
||||||
|
match reset_cycle {
|
||||||
|
"daily" => {
|
||||||
|
if last_reset != today {
|
||||||
|
seq_start
|
||||||
|
} else {
|
||||||
|
seq_current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"monthly" => {
|
||||||
|
if last_reset.month() != today.month() || last_reset.year() != today.year() {
|
||||||
|
seq_start
|
||||||
|
} else {
|
||||||
|
seq_current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"yearly" => {
|
||||||
|
if last_reset.year() != today.year() {
|
||||||
|
seq_start
|
||||||
|
} else {
|
||||||
|
seq_current
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => seq_current, // "never" 或其他值不重置
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将数据库模型转换为响应 DTO。
|
||||||
|
fn model_to_resp(m: &numbering_rule::Model) -> NumberingRuleResp {
|
||||||
|
NumberingRuleResp {
|
||||||
|
id: m.id,
|
||||||
|
name: m.name.clone(),
|
||||||
|
code: m.code.clone(),
|
||||||
|
prefix: m.prefix.clone(),
|
||||||
|
date_format: m.date_format.clone(),
|
||||||
|
seq_length: m.seq_length,
|
||||||
|
seq_start: m.seq_start,
|
||||||
|
seq_current: m.seq_current,
|
||||||
|
separator: m.separator.clone(),
|
||||||
|
reset_cycle: m.reset_cycle.clone(),
|
||||||
|
last_reset_date: m.last_reset_date.map(|d| d.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
291
crates/erp-config/src/service/setting_service.rs
Normal file
291
crates/erp-config/src/service/setting_service.rs
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set,
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::SettingResp;
|
||||||
|
use crate::entity::setting;
|
||||||
|
use crate::error::{ConfigError, ConfigResult};
|
||||||
|
use erp_core::events::EventBus;
|
||||||
|
use erp_core::types::Pagination;
|
||||||
|
|
||||||
|
/// Setting scope hierarchy constants.
|
||||||
|
const SCOPE_PLATFORM: &str = "platform";
|
||||||
|
const SCOPE_TENANT: &str = "tenant";
|
||||||
|
const SCOPE_ORG: &str = "org";
|
||||||
|
const SCOPE_USER: &str = "user";
|
||||||
|
|
||||||
|
/// Setting CRUD service — manage hierarchical configuration values.
|
||||||
|
///
|
||||||
|
/// Settings support a 4-level inheritance hierarchy:
|
||||||
|
/// `user -> org -> tenant -> platform`
|
||||||
|
///
|
||||||
|
/// When reading a setting, if the exact scope+scope_id match is not found,
|
||||||
|
/// the service walks up the hierarchy to find the nearest ancestor value.
|
||||||
|
pub struct SettingService;
|
||||||
|
|
||||||
|
impl SettingService {
|
||||||
|
/// Get a setting value with hierarchical fallback.
|
||||||
|
///
|
||||||
|
/// Resolution order:
|
||||||
|
/// 1. Exact match at (scope, scope_id)
|
||||||
|
/// 2. Walk up the hierarchy based on scope:
|
||||||
|
/// - `user` -> org -> tenant -> platform
|
||||||
|
/// - `org` -> tenant -> platform
|
||||||
|
/// - `tenant` -> platform
|
||||||
|
/// - `platform` -> NotFound
|
||||||
|
pub async fn get(
|
||||||
|
key: &str,
|
||||||
|
scope: &str,
|
||||||
|
scope_id: &Option<Uuid>,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<SettingResp> {
|
||||||
|
// 1. Try exact match
|
||||||
|
if let Some(resp) =
|
||||||
|
Self::find_exact(key, scope, scope_id, tenant_id, db).await?
|
||||||
|
{
|
||||||
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Walk up the hierarchy based on scope
|
||||||
|
let fallback_chain = Self::fallback_chain(scope, scope_id, tenant_id)?;
|
||||||
|
|
||||||
|
for (fb_scope, fb_scope_id) in fallback_chain {
|
||||||
|
if let Some(resp) =
|
||||||
|
Self::find_exact(key, &fb_scope, &fb_scope_id, tenant_id, db).await?
|
||||||
|
{
|
||||||
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(ConfigError::NotFound(format!(
|
||||||
|
"设置 '{}' 在 '{}' 作用域下不存在",
|
||||||
|
key, scope
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a setting value. Creates or updates.
|
||||||
|
///
|
||||||
|
/// If a record with the same (scope, scope_id, key) exists and is not
|
||||||
|
/// soft-deleted, it will be updated. Otherwise a new record is inserted.
|
||||||
|
pub async fn set(
|
||||||
|
key: &str,
|
||||||
|
scope: &str,
|
||||||
|
scope_id: &Option<Uuid>,
|
||||||
|
value: serde_json::Value,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
event_bus: &EventBus,
|
||||||
|
) -> ConfigResult<SettingResp> {
|
||||||
|
// Look for an existing non-deleted record
|
||||||
|
let existing = setting::Entity::find()
|
||||||
|
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(setting::Column::Scope.eq(scope))
|
||||||
|
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||||
|
.filter(setting::Column::SettingKey.eq(key))
|
||||||
|
.filter(setting::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
if let Some(model) = existing {
|
||||||
|
// Update existing record
|
||||||
|
let mut active: setting::ActiveModel = model.into();
|
||||||
|
active.setting_value = Set(value.clone());
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
|
||||||
|
let updated = active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"setting.updated",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({
|
||||||
|
"setting_id": updated.id,
|
||||||
|
"key": key,
|
||||||
|
"scope": scope,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(Self::model_to_resp(&updated))
|
||||||
|
} else {
|
||||||
|
// Insert new record
|
||||||
|
let now = Utc::now();
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
let model = setting::ActiveModel {
|
||||||
|
id: Set(id),
|
||||||
|
tenant_id: Set(tenant_id),
|
||||||
|
scope: Set(scope.to_string()),
|
||||||
|
scope_id: Set(*scope_id),
|
||||||
|
setting_key: Set(key.to_string()),
|
||||||
|
setting_value: Set(value),
|
||||||
|
created_at: Set(now),
|
||||||
|
updated_at: Set(now),
|
||||||
|
created_by: Set(operator_id),
|
||||||
|
updated_by: Set(operator_id),
|
||||||
|
deleted_at: Set(None),
|
||||||
|
version: Set(1),
|
||||||
|
};
|
||||||
|
let inserted = model
|
||||||
|
.insert(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
event_bus.publish(erp_core::events::DomainEvent::new(
|
||||||
|
"setting.created",
|
||||||
|
tenant_id,
|
||||||
|
serde_json::json!({
|
||||||
|
"setting_id": id,
|
||||||
|
"key": key,
|
||||||
|
"scope": scope,
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(Self::model_to_resp(&inserted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all settings for a specific scope and scope_id, with pagination.
|
||||||
|
pub async fn list_by_scope(
|
||||||
|
scope: &str,
|
||||||
|
scope_id: &Option<Uuid>,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
pagination: &Pagination,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<(Vec<SettingResp>, u64)> {
|
||||||
|
let paginator = setting::Entity::find()
|
||||||
|
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(setting::Column::Scope.eq(scope))
|
||||||
|
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||||
|
.filter(setting::Column::DeletedAt.is_null())
|
||||||
|
.paginate(db, pagination.limit());
|
||||||
|
|
||||||
|
let total = paginator
|
||||||
|
.num_items()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let page_index = pagination.page.unwrap_or(1).saturating_sub(1) as u64;
|
||||||
|
let models = paginator
|
||||||
|
.fetch_page(page_index)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
let resps: Vec<SettingResp> =
|
||||||
|
models.iter().map(Self::model_to_resp).collect();
|
||||||
|
|
||||||
|
Ok((resps, total))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft-delete a setting by setting the `deleted_at` timestamp.
|
||||||
|
pub async fn delete(
|
||||||
|
key: &str,
|
||||||
|
scope: &str,
|
||||||
|
scope_id: &Option<Uuid>,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
operator_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<()> {
|
||||||
|
let model = setting::Entity::find()
|
||||||
|
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(setting::Column::Scope.eq(scope))
|
||||||
|
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||||
|
.filter(setting::Column::SettingKey.eq(key))
|
||||||
|
.filter(setting::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
ConfigError::NotFound(format!(
|
||||||
|
"设置 '{}' 在 '{}' 作用域下不存在",
|
||||||
|
key, scope
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut active: setting::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(Utc::now()));
|
||||||
|
active.updated_at = Set(Utc::now());
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active
|
||||||
|
.update(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 内部辅助方法 ----
|
||||||
|
|
||||||
|
/// Find an exact setting match by key, scope, and scope_id.
|
||||||
|
async fn find_exact(
|
||||||
|
key: &str,
|
||||||
|
scope: &str,
|
||||||
|
scope_id: &Option<Uuid>,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
db: &sea_orm::DatabaseConnection,
|
||||||
|
) -> ConfigResult<Option<SettingResp>> {
|
||||||
|
let model = setting::Entity::find()
|
||||||
|
.filter(setting::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(setting::Column::Scope.eq(scope))
|
||||||
|
.filter(setting::Column::ScopeId.eq(*scope_id))
|
||||||
|
.filter(setting::Column::SettingKey.eq(key))
|
||||||
|
.filter(setting::Column::DeletedAt.is_null())
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
.map_err(|e| ConfigError::Validation(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(model.as_ref().map(Self::model_to_resp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the fallback chain for hierarchical lookup.
|
||||||
|
///
|
||||||
|
/// Returns a list of (scope, scope_id) tuples to try in order.
|
||||||
|
fn fallback_chain(
|
||||||
|
scope: &str,
|
||||||
|
scope_id: &Option<Uuid>,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
) -> ConfigResult<Vec<(String, Option<Uuid>)>> {
|
||||||
|
match scope {
|
||||||
|
SCOPE_USER => {
|
||||||
|
// user -> org -> tenant -> platform
|
||||||
|
// Note: We cannot resolve the actual org_id from user scope here
|
||||||
|
// without a dependency on auth module. The caller should handle
|
||||||
|
// org-level resolution externally if needed. We skip org fallback
|
||||||
|
// and go directly to tenant.
|
||||||
|
Ok(vec![
|
||||||
|
(SCOPE_TENANT.to_string(), Some(tenant_id)),
|
||||||
|
(SCOPE_PLATFORM.to_string(), None),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
SCOPE_ORG => Ok(vec![
|
||||||
|
(SCOPE_TENANT.to_string(), Some(tenant_id)),
|
||||||
|
(SCOPE_PLATFORM.to_string(), None),
|
||||||
|
]),
|
||||||
|
SCOPE_TENANT => {
|
||||||
|
Ok(vec![(SCOPE_PLATFORM.to_string(), None)])
|
||||||
|
}
|
||||||
|
SCOPE_PLATFORM => Ok(vec![]),
|
||||||
|
_ => Err(ConfigError::Validation(format!(
|
||||||
|
"不支持的作用域类型: '{}'",
|
||||||
|
scope
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a SeaORM model to a response DTO.
|
||||||
|
fn model_to_resp(model: &setting::Model) -> SettingResp {
|
||||||
|
SettingResp {
|
||||||
|
id: model.id,
|
||||||
|
scope: model.scope.clone(),
|
||||||
|
scope_id: model.scope_id,
|
||||||
|
setting_key: model.setting_key.clone(),
|
||||||
|
setting_value: model.setting_value.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod events;
|
pub mod events;
|
||||||
pub mod module;
|
pub mod module;
|
||||||
|
pub mod rbac;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
use erp_core::error::AppError;
|
use crate::error::AppError;
|
||||||
use erp_core::types::TenantContext;
|
use crate::types::TenantContext;
|
||||||
|
|
||||||
/// Check whether the `TenantContext` includes the specified permission code.
|
/// Check whether the `TenantContext` includes the specified permission code.
|
||||||
///
|
///
|
||||||
@@ -24,5 +24,6 @@ serde_json.workspace = true
|
|||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
erp-server-migration = { path = "migration" }
|
erp-server-migration = { path = "migration" }
|
||||||
erp-auth.workspace = true
|
erp-auth.workspace = true
|
||||||
|
erp-config.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ mod m20260411_000008_create_user_roles;
|
|||||||
mod m20260411_000009_create_organizations;
|
mod m20260411_000009_create_organizations;
|
||||||
mod m20260411_000010_create_departments;
|
mod m20260411_000010_create_departments;
|
||||||
mod m20260411_000011_create_positions;
|
mod m20260411_000011_create_positions;
|
||||||
|
mod m20260412_000012_create_dictionaries;
|
||||||
|
mod m20260412_000013_create_dictionary_items;
|
||||||
|
mod m20260412_000014_create_menus;
|
||||||
|
mod m20260412_000015_create_menu_roles;
|
||||||
|
mod m20260412_000016_create_settings;
|
||||||
|
mod m20260412_000017_create_numbering_rules;
|
||||||
|
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
@@ -29,6 +35,12 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260411_000009_create_organizations::Migration),
|
Box::new(m20260411_000009_create_organizations::Migration),
|
||||||
Box::new(m20260411_000010_create_departments::Migration),
|
Box::new(m20260411_000010_create_departments::Migration),
|
||||||
Box::new(m20260411_000011_create_positions::Migration),
|
Box::new(m20260411_000011_create_positions::Migration),
|
||||||
|
Box::new(m20260412_000012_create_dictionaries::Migration),
|
||||||
|
Box::new(m20260412_000013_create_dictionary_items::Migration),
|
||||||
|
Box::new(m20260412_000014_create_menus::Migration),
|
||||||
|
Box::new(m20260412_000015_create_menu_roles::Migration),
|
||||||
|
Box::new(m20260412_000016_create_settings::Migration),
|
||||||
|
Box::new(m20260412_000017_create_numbering_rules::Migration),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Dictionaries::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Dictionaries::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Dictionaries::TenantId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(Dictionaries::Name).string().not_null())
|
||||||
|
.col(ColumnDef::new(Dictionaries::Code).string().not_null())
|
||||||
|
.col(ColumnDef::new(Dictionaries::Description).text().null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Dictionaries::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Dictionaries::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Dictionaries::CreatedBy).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(Dictionaries::UpdatedBy).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Dictionaries::DeletedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Dictionaries::Version)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(1),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_dictionaries_tenant_id")
|
||||||
|
.table(Dictionaries::Table)
|
||||||
|
.col(Dictionaries::TenantId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
"CREATE UNIQUE INDEX idx_dictionaries_tenant_code ON dictionaries (tenant_id, code) WHERE deleted_at IS NULL".to_string(),
|
||||||
|
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Dictionaries::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Dictionaries {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
TenantId,
|
||||||
|
Name,
|
||||||
|
Code,
|
||||||
|
Description,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
CreatedBy,
|
||||||
|
UpdatedBy,
|
||||||
|
DeletedAt,
|
||||||
|
Version,
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(DictionaryItems::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(DictionaryItems::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(DictionaryItems::TenantId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(DictionaryItems::DictionaryId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(DictionaryItems::Label).string().not_null())
|
||||||
|
.col(ColumnDef::new(DictionaryItems::Value).string().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(DictionaryItems::SortOrder)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(0),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(DictionaryItems::Color).string().null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(DictionaryItems::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(DictionaryItems::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(DictionaryItems::CreatedBy).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(DictionaryItems::UpdatedBy).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(DictionaryItems::DeletedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(DictionaryItems::Version)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(1),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_dict_items_dictionary")
|
||||||
|
.from(DictionaryItems::Table, DictionaryItems::DictionaryId)
|
||||||
|
.to(Dictionaries::Table, Dictionaries::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_dict_items_dictionary_id")
|
||||||
|
.table(DictionaryItems::Table)
|
||||||
|
.col(DictionaryItems::DictionaryId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
"CREATE UNIQUE INDEX idx_dict_items_dict_value ON dictionary_items (dictionary_id, value) WHERE deleted_at IS NULL".to_string(),
|
||||||
|
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(DictionaryItems::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Dictionaries {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum DictionaryItems {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
TenantId,
|
||||||
|
DictionaryId,
|
||||||
|
Label,
|
||||||
|
Value,
|
||||||
|
SortOrder,
|
||||||
|
Color,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
CreatedBy,
|
||||||
|
UpdatedBy,
|
||||||
|
DeletedAt,
|
||||||
|
Version,
|
||||||
|
}
|
||||||
129
crates/erp-server/migration/src/m20260412_000014_create_menus.rs
Normal file
129
crates/erp-server/migration/src/m20260412_000014_create_menus.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Menus::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Menus::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Menus::TenantId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(Menus::ParentId).uuid().null())
|
||||||
|
.col(ColumnDef::new(Menus::Title).string().not_null())
|
||||||
|
.col(ColumnDef::new(Menus::Path).string().null())
|
||||||
|
.col(ColumnDef::new(Menus::Icon).string().null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Menus::SortOrder)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(0),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Menus::Visible)
|
||||||
|
.boolean()
|
||||||
|
.not_null()
|
||||||
|
.default(true),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Menus::MenuType)
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.default("page"),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Menus::Permission).string().null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Menus::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Menus::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Menus::CreatedBy).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(Menus::UpdatedBy).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Menus::DeletedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Menus::Version)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(1),
|
||||||
|
)
|
||||||
|
.foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk_menus_parent")
|
||||||
|
.from(Menus::Table, Menus::ParentId)
|
||||||
|
.to(Menus::Table, Menus::Id)
|
||||||
|
.on_delete(ForeignKeyAction::Cascade),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_menus_tenant_id")
|
||||||
|
.table(Menus::Table)
|
||||||
|
.col(Menus::TenantId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_menus_parent_id")
|
||||||
|
.table(Menus::Table)
|
||||||
|
.col(Menus::ParentId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Menus::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Menus {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
TenantId,
|
||||||
|
ParentId,
|
||||||
|
Title,
|
||||||
|
Path,
|
||||||
|
Icon,
|
||||||
|
SortOrder,
|
||||||
|
Visible,
|
||||||
|
MenuType,
|
||||||
|
Permission,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
CreatedBy,
|
||||||
|
UpdatedBy,
|
||||||
|
DeletedAt,
|
||||||
|
Version,
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(MenuRoles::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(ColumnDef::new(MenuRoles::MenuId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(MenuRoles::RoleId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(MenuRoles::TenantId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(MenuRoles::Id).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(MenuRoles::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(MenuRoles::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(MenuRoles::CreatedBy).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(MenuRoles::UpdatedBy).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(MenuRoles::DeletedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(MenuRoles::Version)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(1),
|
||||||
|
)
|
||||||
|
.primary_key(Index::create().col(MenuRoles::Id))
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
"CREATE UNIQUE INDEX idx_menu_roles_unique ON menu_roles (menu_id, role_id) WHERE deleted_at IS NULL".to_string(),
|
||||||
|
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_menu_roles_menu_id")
|
||||||
|
.table(MenuRoles::Table)
|
||||||
|
.col(MenuRoles::MenuId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_menu_roles_role_id")
|
||||||
|
.table(MenuRoles::Table)
|
||||||
|
.col(MenuRoles::RoleId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(MenuRoles::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum MenuRoles {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
MenuId,
|
||||||
|
RoleId,
|
||||||
|
TenantId,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
CreatedBy,
|
||||||
|
UpdatedBy,
|
||||||
|
DeletedAt,
|
||||||
|
Version,
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(Settings::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Settings::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Settings::TenantId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(Settings::Scope).string().not_null())
|
||||||
|
.col(ColumnDef::new(Settings::ScopeId).uuid().null())
|
||||||
|
.col(ColumnDef::new(Settings::SettingKey).string().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Settings::SettingValue)
|
||||||
|
.json_binary()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::val("{}")),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Settings::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Settings::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(Settings::CreatedBy).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(Settings::UpdatedBy).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Settings::DeletedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(Settings::Version)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(1),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_settings_tenant_id")
|
||||||
|
.table(Settings::Table)
|
||||||
|
.col(Settings::TenantId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
"CREATE UNIQUE INDEX idx_settings_scope_key ON settings (scope, scope_id, setting_key) WHERE deleted_at IS NULL".to_string(),
|
||||||
|
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(Settings::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum Settings {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
TenantId,
|
||||||
|
Scope,
|
||||||
|
ScopeId,
|
||||||
|
SettingKey,
|
||||||
|
SettingValue,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
CreatedBy,
|
||||||
|
UpdatedBy,
|
||||||
|
DeletedAt,
|
||||||
|
Version,
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.create_table(
|
||||||
|
Table::create()
|
||||||
|
.table(NumberingRules::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(NumberingRules::Id)
|
||||||
|
.uuid()
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(NumberingRules::TenantId).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(NumberingRules::Name).string().not_null())
|
||||||
|
.col(ColumnDef::new(NumberingRules::Code).string().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(NumberingRules::Prefix)
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.default(""),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(NumberingRules::DateFormat).string().null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(NumberingRules::SeqLength)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(4),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(NumberingRules::SeqStart)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(1),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(NumberingRules::SeqCurrent)
|
||||||
|
.big_integer()
|
||||||
|
.not_null()
|
||||||
|
.default(0),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(NumberingRules::Separator)
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.default("-"),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(NumberingRules::ResetCycle)
|
||||||
|
.string()
|
||||||
|
.not_null()
|
||||||
|
.default("never"),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(NumberingRules::LastResetDate).date().null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(NumberingRules::CreatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(NumberingRules::UpdatedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.not_null()
|
||||||
|
.default(Expr::current_timestamp()),
|
||||||
|
)
|
||||||
|
.col(ColumnDef::new(NumberingRules::CreatedBy).uuid().not_null())
|
||||||
|
.col(ColumnDef::new(NumberingRules::UpdatedBy).uuid().not_null())
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(NumberingRules::DeletedAt)
|
||||||
|
.timestamp_with_time_zone()
|
||||||
|
.null(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(NumberingRules::Version)
|
||||||
|
.integer()
|
||||||
|
.not_null()
|
||||||
|
.default(1),
|
||||||
|
)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_numbering_rules_tenant_id")
|
||||||
|
.table(NumberingRules::Table)
|
||||||
|
.col(NumberingRules::TenantId)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
manager.get_connection().execute(sea_orm::Statement::from_string(
|
||||||
|
sea_orm::DatabaseBackend::Postgres,
|
||||||
|
"CREATE UNIQUE INDEX idx_numbering_rules_tenant_code ON numbering_rules (tenant_id, code) WHERE deleted_at IS NULL".to_string(),
|
||||||
|
)).await.map_err(|e| DbErr::Custom(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
manager
|
||||||
|
.drop_table(Table::drop().table(NumberingRules::Table).to_owned())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden)]
|
||||||
|
enum NumberingRules {
|
||||||
|
Table,
|
||||||
|
Id,
|
||||||
|
TenantId,
|
||||||
|
Name,
|
||||||
|
Code,
|
||||||
|
Prefix,
|
||||||
|
DateFormat,
|
||||||
|
SeqLength,
|
||||||
|
SeqStart,
|
||||||
|
SeqCurrent,
|
||||||
|
Separator,
|
||||||
|
ResetCycle,
|
||||||
|
LastResetDate,
|
||||||
|
CreatedAt,
|
||||||
|
UpdatedAt,
|
||||||
|
CreatedBy,
|
||||||
|
UpdatedBy,
|
||||||
|
DeletedAt,
|
||||||
|
Version,
|
||||||
|
}
|
||||||
@@ -101,8 +101,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let auth_module = erp_auth::AuthModule::new();
|
let auth_module = erp_auth::AuthModule::new();
|
||||||
tracing::info!(module = auth_module.name(), version = auth_module.version(), "Auth module initialized");
|
tracing::info!(module = auth_module.name(), version = auth_module.version(), "Auth module initialized");
|
||||||
|
|
||||||
// Initialize module registry and register auth module
|
// Initialize config module
|
||||||
let registry = ModuleRegistry::new().register(auth_module);
|
let config_module = erp_config::ConfigModule::new();
|
||||||
|
tracing::info!(module = config_module.name(), version = config_module.version(), "Config module initialized");
|
||||||
|
|
||||||
|
// Initialize module registry and register modules
|
||||||
|
let registry = ModuleRegistry::new()
|
||||||
|
.register(auth_module)
|
||||||
|
.register(config_module);
|
||||||
tracing::info!(module_count = registry.modules().len(), "Modules registered");
|
tracing::info!(module_count = registry.modules().len(), "Modules registered");
|
||||||
|
|
||||||
// Register event handlers
|
// Register event handlers
|
||||||
@@ -139,6 +145,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
|
|
||||||
// Protected routes (JWT authentication required)
|
// Protected routes (JWT authentication required)
|
||||||
let protected_routes = erp_auth::AuthModule::protected_routes()
|
let protected_routes = erp_auth::AuthModule::protected_routes()
|
||||||
|
.merge(erp_config::ConfigModule::protected_routes())
|
||||||
.layer(middleware::from_fn(move |req, next| {
|
.layer(middleware::from_fn(move |req, next| {
|
||||||
let secret = jwt_secret.clone();
|
let secret = jwt_secret.clone();
|
||||||
async move { jwt_auth_middleware_fn(secret, req, next).await }
|
async move { jwt_auth_middleware_fn(secret, req, next).await }
|
||||||
|
|||||||
@@ -50,3 +50,13 @@ impl FromRef<AppState> for erp_auth::AuthState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Allow erp-config handlers to extract their required state without depending on erp-server.
|
||||||
|
impl FromRef<AppState> for erp_config::ConfigState {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
Self {
|
||||||
|
db: state.db.clone(),
|
||||||
|
event_bus: state.event_bus.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
329
plans/stateless-swimming-perlis.md
Normal file
329
plans/stateless-swimming-perlis.md
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
# Phase 3: 系统配置模块 — 实施计划
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Phase 1(基础设施)和 Phase 2(身份与权限)已完成。Phase 3 需要实现系统配置模块(erp-config),提供数据字典、动态菜单、系统参数、编号规则、i18n 框架和主题自定义能力。当前 `erp-config` 仅为 placeholder。
|
||||||
|
|
||||||
|
## 前置重构:将 RBAC 移至 erp-core
|
||||||
|
|
||||||
|
**问题:** `require_permission` 在 `erp-auth/src/middleware/rbac.rs` 中,但只依赖 `erp-core` 的 `TenantContext` 和 `AppError`。erp-config 不能依赖 erp-auth(架构铁律:业务 crate 禁止直接依赖)。
|
||||||
|
|
||||||
|
**方案:**
|
||||||
|
1. 将 `erp-auth/src/middleware/rbac.rs` → `erp-core/src/rbac.rs`
|
||||||
|
2. `erp-core/src/lib.rs` 添加 `pub mod rbac;`
|
||||||
|
3. 更新 `erp-auth` 所有 handler 的 import:`crate::middleware::rbac` → `erp_core::rbac`
|
||||||
|
4. `erp-auth/src/middleware/mod.rs` 移除 `pub mod rbac;`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: erp-config 骨架 + ConfigState
|
||||||
|
|
||||||
|
**目标:** 创建可编译的最小 crate,注册到 erp-server。
|
||||||
|
|
||||||
|
**创建/修改文件:**
|
||||||
|
- 修改: `crates/erp-config/Cargo.toml` — 添加完整依赖
|
||||||
|
- 修改: `crates/erp-config/src/lib.rs` — 模块声明 + re-export
|
||||||
|
- 创建: `crates/erp-config/src/config_state.rs` — ConfigState { db, event_bus }
|
||||||
|
- 创建: `crates/erp-config/src/error.rs` — ConfigError 枚举
|
||||||
|
- 创建: `crates/erp-config/src/module.rs` — ConfigModule + 空路由
|
||||||
|
- 创建: `crates/erp-config/src/dto.rs` — 占位
|
||||||
|
- 创建: `crates/erp-config/src/entity/mod.rs` — 占位
|
||||||
|
- 创建: `crates/erp-config/src/service/mod.rs` — 占位
|
||||||
|
- 创建: `crates/erp-config/src/handler/mod.rs` — 占位
|
||||||
|
- 修改: `crates/erp-server/Cargo.toml` — 添加 erp-config 依赖
|
||||||
|
- 修改: `crates/erp-server/src/state.rs` — 添加 FromRef<AppState> for ConfigState
|
||||||
|
- 修改: `crates/erp-server/src/main.rs` — 注册 ConfigModule
|
||||||
|
|
||||||
|
**验证:** `cargo check` 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: 数据库迁移(6 张表)
|
||||||
|
|
||||||
|
**目标:** 创建所有配置模块表。
|
||||||
|
|
||||||
|
**创建文件(`crates/erp-server/migration/src/`):**
|
||||||
|
- `m20260412_000012_create_dictionaries.rs` — 字典分类表
|
||||||
|
- `m20260412_000013_create_dictionary_items.rs` — 字典项表
|
||||||
|
- `m20260412_000014_create_menus.rs` — 菜单树形表
|
||||||
|
- `m20260412_000015_create_menu_roles.rs` — 菜单-角色关联表
|
||||||
|
- `m20260412_000016_create_settings.rs` — 分层键值配置表
|
||||||
|
- `m20260412_000017_create_numbering_rules.rs` — 编号规则表
|
||||||
|
- 修改: `lib.rs` — 注册新迁移
|
||||||
|
|
||||||
|
**表结构:**
|
||||||
|
|
||||||
|
### dictionaries
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | UUIDv7 |
|
||||||
|
| tenant_id | uuid NOT NULL | 租户 ID |
|
||||||
|
| name | string NOT NULL | 显示名称 |
|
||||||
|
| code | string NOT NULL | 字典键(如 gender) |
|
||||||
|
| description | text NULL | 说明 |
|
||||||
|
| + 标准审计字段 | | |
|
||||||
|
| 唯一索引: | `(tenant_id, code) WHERE deleted_at IS NULL` | |
|
||||||
|
|
||||||
|
### dictionary_items
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | UUIDv7 |
|
||||||
|
| tenant_id | uuid NOT NULL | |
|
||||||
|
| dictionary_id | uuid NOT NULL | FK → dictionaries |
|
||||||
|
| label | string NOT NULL | 显示标签 |
|
||||||
|
| value | string NOT NULL | 存储值 |
|
||||||
|
| sort_order | int DEFAULT 0 | 排序 |
|
||||||
|
| color | string NULL | 颜色标签 |
|
||||||
|
| + 标准审计字段 | | |
|
||||||
|
| 唯一索引: | `(dictionary_id, value) WHERE deleted_at IS NULL` | |
|
||||||
|
|
||||||
|
### menus(树形自引用)
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | UUIDv7 |
|
||||||
|
| tenant_id | uuid NOT NULL | |
|
||||||
|
| parent_id | uuid NULL | 自引用 |
|
||||||
|
| title | string NOT NULL | 菜单标题 |
|
||||||
|
| path | string NULL | 前端路由 |
|
||||||
|
| icon | string NULL | 图标名 |
|
||||||
|
| sort_order | int DEFAULT 0 | |
|
||||||
|
| visible | bool DEFAULT true | |
|
||||||
|
| menu_type | string DEFAULT 'page' | page/link/button |
|
||||||
|
| permission | string NULL | 所需权限码 |
|
||||||
|
| + 标准审计字段 | | |
|
||||||
|
|
||||||
|
### menu_roles(复合主键)
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| menu_id | uuid NOT NULL | PK 组成 |
|
||||||
|
| role_id | uuid NOT NULL | PK 组成 |
|
||||||
|
| tenant_id | uuid NOT NULL | |
|
||||||
|
| + 标准审计字段 | | |
|
||||||
|
| 唯一索引: | `(menu_id, role_id) WHERE deleted_at IS NULL` | |
|
||||||
|
|
||||||
|
### settings(分层配置)
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | UUIDv7 |
|
||||||
|
| tenant_id | uuid NOT NULL | |
|
||||||
|
| scope | string NOT NULL | platform/tenant/org/user |
|
||||||
|
| scope_id | uuid NULL | 平台=NULL, 租户=tenant_id 等 |
|
||||||
|
| setting_key | string NOT NULL | |
|
||||||
|
| setting_value | jsonb DEFAULT '{}' | |
|
||||||
|
| + 标准审计字段 | | |
|
||||||
|
| 唯一索引: | `(scope, scope_id, setting_key) WHERE deleted_at IS NULL` | |
|
||||||
|
|
||||||
|
### numbering_rules
|
||||||
|
| 列 | 类型 | 说明 |
|
||||||
|
|---|---|---|
|
||||||
|
| id | uuid PK | UUIDv7 |
|
||||||
|
| tenant_id | uuid NOT NULL | |
|
||||||
|
| name | string NOT NULL | 规则名称 |
|
||||||
|
| code | string NOT NULL | 唯一编码(如 INV) |
|
||||||
|
| prefix | string DEFAULT '' | 前缀 |
|
||||||
|
| date_format | string NULL | 如 %Y%m%d |
|
||||||
|
| seq_length | int DEFAULT 4 | 序列位数 |
|
||||||
|
| seq_start | int DEFAULT 1 | 起始值 |
|
||||||
|
| seq_current | bigint DEFAULT 0 | 当前序列 |
|
||||||
|
| separator | string DEFAULT '-' | 分隔符 |
|
||||||
|
| reset_cycle | string DEFAULT 'never' | never/daily/monthly/yearly |
|
||||||
|
| last_reset_date | date NULL | |
|
||||||
|
| + 标准审计字段 | | |
|
||||||
|
| 唯一索引: | `(tenant_id, code) WHERE deleted_at IS NULL` | |
|
||||||
|
|
||||||
|
**验证:** `cargo run -p erp-server` 启动后 `\dt` 可见新表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: SeaORM Entity
|
||||||
|
|
||||||
|
**目标:** 为 6 张表创建 Entity 定义。
|
||||||
|
|
||||||
|
**创建文件(`crates/erp-config/src/entity/`):**
|
||||||
|
- `mod.rs` — 导出所有实体
|
||||||
|
- `dictionary.rs` — Dictionary Entity
|
||||||
|
- `dictionary_item.rs` — DictionaryItem Entity
|
||||||
|
- `menu.rs` — Menu Entity
|
||||||
|
- `menu_role.rs` — MenuRole Entity(复合主键模式,参考 role_permission)
|
||||||
|
- `setting.rs` — Setting Entity
|
||||||
|
- `numbering_rule.rs` — NumberingRule Entity
|
||||||
|
|
||||||
|
**模式:** 参考 `erp-auth/src/entity/role.rs`,包含 Relation 和 Related 实现。
|
||||||
|
|
||||||
|
**验证:** `cargo check` 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: DTO 定义
|
||||||
|
|
||||||
|
**目标:** 定义所有配置端点的请求/响应类型。
|
||||||
|
|
||||||
|
**修改文件:** `crates/erp-config/src/dto.rs`
|
||||||
|
|
||||||
|
**包含:**
|
||||||
|
- 字典 DTO:DictionaryResp, DictionaryItemResp, CreateDictionaryReq, UpdateDictionaryReq, CreateDictionaryItemReq, UpdateDictionaryItemReq
|
||||||
|
- 菜单 DTO:MenuResp(含 children 递归), CreateMenuReq, UpdateMenuReq, BatchSaveMenuReq
|
||||||
|
- 设置 DTO:SettingResp, UpdateSettingReq
|
||||||
|
- 编号规则 DTO:NumberingRuleResp, CreateNumberingRuleReq, UpdateNumberingRuleReq, GenerateNumberResp
|
||||||
|
- 主题 DTO:ThemeResp, UpdateThemeReq(委托 settings 存储)
|
||||||
|
- 语言 DTO:LanguageResp, UpdateLanguageReq(委托 settings 存储)
|
||||||
|
|
||||||
|
**验证:** `cargo check` 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Service — 字典 + 系统参数
|
||||||
|
|
||||||
|
**创建文件(`crates/erp-config/src/service/`):**
|
||||||
|
- `dictionary_service.rs` — CRUD + 字典项管理 + 按 code 查询
|
||||||
|
- `setting_service.rs` — 分层读取(User>Org>Tenant>Platform)+ 写入
|
||||||
|
|
||||||
|
**关键逻辑:**
|
||||||
|
- SettingService::get 实现分层覆盖查找
|
||||||
|
- SettingService::set 使用 upsert 语义(INSERT ON CONFLICT UPDATE)
|
||||||
|
- DictionaryService 遵循 RoleService 的无状态模式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Service — 菜单 + 编号规则
|
||||||
|
|
||||||
|
**创建文件(`crates/erp-config/src/service/`):**
|
||||||
|
- `menu_service.rs` — 菜单树构建 + 角色过滤 + 批量保存
|
||||||
|
- `numbering_service.rs` — 编号规则 CRUD + generate_number
|
||||||
|
|
||||||
|
**关键逻辑:**
|
||||||
|
- MenuService::get_menu_tree — 按 role_ids 过滤 menu_role,HashMap 分组构建树(参考 OrgService 的 build_org_tree)
|
||||||
|
- NumberingService::generate_number — PostgreSQL advisory_lock + 事务内序列递增:
|
||||||
|
```sql
|
||||||
|
SELECT pg_advisory_xact_lock(hashtext($1), $2::int)
|
||||||
|
-- $1 = rule_code, $2 = hash(tenant_id)
|
||||||
|
```
|
||||||
|
生成格式:`{prefix}{separator}{date}{separator}{seq_padded}`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Handler 层
|
||||||
|
|
||||||
|
**创建文件(`crates/erp-config/src/handler/`):**
|
||||||
|
- `dictionary_handler.rs` — 5 个端点
|
||||||
|
- `menu_handler.rs` — 2 个端点
|
||||||
|
- `setting_handler.rs` — 2 个端点
|
||||||
|
- `numbering_handler.rs` — 4 个端点(含 generate)
|
||||||
|
- `theme_handler.rs` — 2 个端点(委托 SettingService)
|
||||||
|
- `language_handler.rs` — 2 个端点(委托 SettingService)
|
||||||
|
|
||||||
|
**端点映射:**
|
||||||
|
```
|
||||||
|
GET/POST /config/dictionaries
|
||||||
|
PUT/DELETE /config/dictionaries/{id}
|
||||||
|
GET/PUT /config/menus
|
||||||
|
GET/PUT /config/settings/{key}
|
||||||
|
GET/POST /config/numbering-rules
|
||||||
|
PUT /config/numbering-rules/{id}
|
||||||
|
POST /config/numbering-rules/{id}/generate
|
||||||
|
GET/PUT /config/themes
|
||||||
|
GET /config/languages
|
||||||
|
PUT /config/languages/{code}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: 模块注册 + 种子数据
|
||||||
|
|
||||||
|
**修改文件:**
|
||||||
|
- `crates/erp-config/src/module.rs` — 填充真实路由
|
||||||
|
- `crates/erp-auth/src/service/seed.rs` — 添加配置模块权限(dictionary/menu/setting/numbering/theme/language)
|
||||||
|
- `crates/erp-server/src/main.rs` — 确认路由集成
|
||||||
|
|
||||||
|
**新增种子权限(17 个):**
|
||||||
|
- dictionary: create/read/update/delete/list
|
||||||
|
- menu: read/update
|
||||||
|
- setting: read/update
|
||||||
|
- numbering: create/read/update/generate
|
||||||
|
- theme: read/update
|
||||||
|
- language: read/update
|
||||||
|
|
||||||
|
**验证:** `cargo check` + `cargo test` 通过,服务器启动后 Swagger UI 可见新端点
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 9: 前端 API 层 + Settings 页面框架
|
||||||
|
|
||||||
|
**创建文件(`apps/web/src/`):**
|
||||||
|
- `api/dictionaries.ts` — 字典 CRUD API
|
||||||
|
- `api/menus.ts` — 菜单 API
|
||||||
|
- `api/settings.ts` — 设置 API
|
||||||
|
- `api/numberingRules.ts` — 编号规则 API
|
||||||
|
- `pages/Settings.tsx` — Tabs 壳页面
|
||||||
|
|
||||||
|
**修改文件:**
|
||||||
|
- `App.tsx` — 替换 settings 占位组件
|
||||||
|
|
||||||
|
**Tabs 结构:** 数据字典 | 菜单配置 | 编号规则 | 系统参数 | 主题设置
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 10: 前端设置子页面
|
||||||
|
|
||||||
|
**创建文件(`apps/web/src/pages/settings/`):**
|
||||||
|
- `DictionaryManager.tsx` — Table + 展开行显示字典项,参考 Roles.tsx
|
||||||
|
- `MenuConfig.tsx` — Tree + 编辑表单,参考 Organizations.tsx
|
||||||
|
- `NumberingRules.tsx` — Table + Modal CRUD + 生成按钮
|
||||||
|
- `SystemSettings.tsx` — 键值编辑列表
|
||||||
|
- `ThemeSettings.tsx` — 颜色选择器 + 表单
|
||||||
|
|
||||||
|
**修改文件:**
|
||||||
|
- `Settings.tsx` — 导入子组件
|
||||||
|
- `MainLayout.tsx` — 更新设置菜单图标
|
||||||
|
|
||||||
|
**验证:** `pnpm dev` 启动,访问 /settings 各 tab 可正常交互
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 依赖图
|
||||||
|
|
||||||
|
```
|
||||||
|
前置重构(RBAC→erp-core)
|
||||||
|
|
|
||||||
|
Task 1(骨架)
|
||||||
|
|
|
||||||
|
Task 2(迁移)→ Task 3(Entity)→ Task 4(DTO)
|
||||||
|
|
|
||||||
|
+----------------+----------------+
|
||||||
|
| |
|
||||||
|
Task 5(字典+设置 Service) Task 6(菜单+编号 Service)
|
||||||
|
| |
|
||||||
|
+----------------+----------------+
|
||||||
|
|
|
||||||
|
Task 7(Handler)
|
||||||
|
|
|
||||||
|
Task 8(集成+种子)
|
||||||
|
|
|
||||||
|
Task 9(前端API+壳)
|
||||||
|
|
|
||||||
|
Task 10(前端页面)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
- [ ] `cargo check` 全 workspace 通过
|
||||||
|
- [ ] `cargo test --workspace` 全部通过
|
||||||
|
- [ ] Docker 环境正常启动
|
||||||
|
- [ ] 所有迁移可正/反向执行
|
||||||
|
- [ ] API 端点可通过 Swagger UI 测试
|
||||||
|
- [ ] 前端 /settings 页面各 Tab 正常工作
|
||||||
|
- [ ] 所有代码已提交
|
||||||
|
|
||||||
|
## 关键参考文件
|
||||||
|
|
||||||
|
| 用途 | 文件路径 |
|
||||||
|
|------|----------|
|
||||||
|
| Service 模式 | `crates/erp-auth/src/service/role_service.rs` |
|
||||||
|
| Handler 模式 | `crates/erp-auth/src/handler/role_handler.rs` |
|
||||||
|
| 树构建模式 | `crates/erp-auth/src/service/org_service.rs` |
|
||||||
|
| 迁移模式 | `crates/erp-server/migration/src/m20260411_000005_create_roles.rs` |
|
||||||
|
| State 桥接 | `crates/erp-server/src/state.rs` |
|
||||||
|
| 复合主键 Entity | `crates/erp-auth/src/entity/role_permission.rs` |
|
||||||
|
| 前端 Table CRUD | `apps/web/src/pages/Roles.tsx` |
|
||||||
|
| 前端树形展示 | `apps/web/src/pages/Organizations.tsx` |
|
||||||
|
| RBAC 工具函数 | `crates/erp-auth/src/middleware/rbac.rs`(待迁移到 erp-core) |
|
||||||
Reference in New Issue
Block a user