From 0baaf5f7eea057e52cbb84e57ab04e9192ecbc19 Mon Sep 17 00:00:00 2001 From: iven Date: Sat, 11 Apr 2026 08:09:19 +0800 Subject: [PATCH] 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. --- Cargo.lock | 5 + apps/web/src/App.tsx | 3 +- apps/web/src/api/dictionaries.ts | 66 +++ apps/web/src/api/menus.ts | 36 ++ apps/web/src/api/numberingRules.ts | 67 +++ apps/web/src/api/settings.ts | 25 ++ apps/web/src/pages/Settings.tsx | 20 + .../src/pages/settings/DictionaryManager.tsx | 358 +++++++++++++++ apps/web/src/pages/settings/MenuConfig.tsx | 321 ++++++++++++++ .../web/src/pages/settings/NumberingRules.tsx | 298 +++++++++++++ .../web/src/pages/settings/SystemSettings.tsx | 225 ++++++++++ apps/web/src/pages/settings/ThemeSettings.tsx | 104 +++++ crates/erp-auth/src/handler/org_handler.rs | 2 +- crates/erp-auth/src/handler/role_handler.rs | 2 +- crates/erp-auth/src/handler/user_handler.rs | 2 +- crates/erp-auth/src/middleware/mod.rs | 3 +- crates/erp-auth/src/service/seed.rs | 19 +- crates/erp-config/Cargo.toml | 4 + crates/erp-config/src/config_state.rs | 11 + crates/erp-config/src/dto.rs | 218 +++++++++ crates/erp-config/src/entity/dictionary.rs | 35 ++ .../erp-config/src/entity/dictionary_item.rs | 42 ++ crates/erp-config/src/entity/menu.rs | 43 ++ crates/erp-config/src/entity/menu_role.rs | 38 ++ crates/erp-config/src/entity/mod.rs | 6 + .../erp-config/src/entity/numbering_rule.rs | 34 ++ crates/erp-config/src/entity/setting.rs | 27 ++ crates/erp-config/src/error.rs | 41 ++ .../src/handler/dictionary_handler.rs | 163 +++++++ .../src/handler/language_handler.rs | 101 +++++ crates/erp-config/src/handler/menu_handler.rs | 104 +++++ crates/erp-config/src/handler/mod.rs | 6 + .../src/handler/numbering_handler.rs | 119 +++++ .../erp-config/src/handler/setting_handler.rs | 76 ++++ .../erp-config/src/handler/theme_handler.rs | 69 +++ crates/erp-config/src/lib.rs | 11 +- crates/erp-config/src/module.rs | 125 ++++++ .../src/service/dictionary_service.rs | 416 ++++++++++++++++++ crates/erp-config/src/service/menu_service.rs | 355 +++++++++++++++ crates/erp-config/src/service/mod.rs | 4 + .../src/service/numbering_service.rs | 378 ++++++++++++++++ .../erp-config/src/service/setting_service.rs | 291 ++++++++++++ crates/erp-core/src/lib.rs | 1 + .../src/middleware => erp-core/src}/rbac.rs | 4 +- crates/erp-server/Cargo.toml | 1 + crates/erp-server/migration/src/lib.rs | 12 + .../m20260412_000012_create_dictionaries.rs | 92 ++++ ...20260412_000013_create_dictionary_items.rs | 114 +++++ .../src/m20260412_000014_create_menus.rs | 129 ++++++ .../src/m20260412_000015_create_menu_roles.rs | 96 ++++ .../src/m20260412_000016_create_settings.rs | 99 +++++ ...m20260412_000017_create_numbering_rules.rs | 136 ++++++ crates/erp-server/src/main.rs | 11 +- crates/erp-server/src/state.rs | 10 + plans/stateless-swimming-perlis.md | 329 ++++++++++++++ 55 files changed, 5295 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/api/dictionaries.ts create mode 100644 apps/web/src/api/menus.ts create mode 100644 apps/web/src/api/numberingRules.ts create mode 100644 apps/web/src/api/settings.ts create mode 100644 apps/web/src/pages/Settings.tsx create mode 100644 apps/web/src/pages/settings/DictionaryManager.tsx create mode 100644 apps/web/src/pages/settings/MenuConfig.tsx create mode 100644 apps/web/src/pages/settings/NumberingRules.tsx create mode 100644 apps/web/src/pages/settings/SystemSettings.tsx create mode 100644 apps/web/src/pages/settings/ThemeSettings.tsx create mode 100644 crates/erp-config/src/config_state.rs create mode 100644 crates/erp-config/src/dto.rs create mode 100644 crates/erp-config/src/entity/dictionary.rs create mode 100644 crates/erp-config/src/entity/dictionary_item.rs create mode 100644 crates/erp-config/src/entity/menu.rs create mode 100644 crates/erp-config/src/entity/menu_role.rs create mode 100644 crates/erp-config/src/entity/mod.rs create mode 100644 crates/erp-config/src/entity/numbering_rule.rs create mode 100644 crates/erp-config/src/entity/setting.rs create mode 100644 crates/erp-config/src/error.rs create mode 100644 crates/erp-config/src/handler/dictionary_handler.rs create mode 100644 crates/erp-config/src/handler/language_handler.rs create mode 100644 crates/erp-config/src/handler/menu_handler.rs create mode 100644 crates/erp-config/src/handler/mod.rs create mode 100644 crates/erp-config/src/handler/numbering_handler.rs create mode 100644 crates/erp-config/src/handler/setting_handler.rs create mode 100644 crates/erp-config/src/handler/theme_handler.rs create mode 100644 crates/erp-config/src/module.rs create mode 100644 crates/erp-config/src/service/dictionary_service.rs create mode 100644 crates/erp-config/src/service/menu_service.rs create mode 100644 crates/erp-config/src/service/mod.rs create mode 100644 crates/erp-config/src/service/numbering_service.rs create mode 100644 crates/erp-config/src/service/setting_service.rs rename crates/{erp-auth/src/middleware => erp-core/src}/rbac.rs (97%) create mode 100644 crates/erp-server/migration/src/m20260412_000012_create_dictionaries.rs create mode 100644 crates/erp-server/migration/src/m20260412_000013_create_dictionary_items.rs create mode 100644 crates/erp-server/migration/src/m20260412_000014_create_menus.rs create mode 100644 crates/erp-server/migration/src/m20260412_000015_create_menu_roles.rs create mode 100644 crates/erp-server/migration/src/m20260412_000016_create_settings.rs create mode 100644 crates/erp-server/migration/src/m20260412_000017_create_numbering_rules.rs create mode 100644 plans/stateless-swimming-perlis.md diff --git a/Cargo.lock b/Cargo.lock index 0aecd8c..b02d3ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -838,15 +838,19 @@ name = "erp-config" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "axum", "chrono", "erp-core", "sea-orm", "serde", "serde_json", + "thiserror", "tokio", "tracing", + "utoipa", "uuid", + "validator", ] [[package]] @@ -891,6 +895,7 @@ dependencies = [ "config", "erp-auth", "erp-common", + "erp-config", "erp-core", "erp-server-migration", "redis", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0960674..ffb59f0 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -8,6 +8,7 @@ import Home from './pages/Home'; import Roles from './pages/Roles'; import Users from './pages/Users'; import Organizations from './pages/Organizations'; +import Settings from './pages/Settings'; import { useAuthStore } from './stores/auth'; import { useAppStore } from './stores/app'; @@ -45,7 +46,7 @@ export default function App() { } /> } /> } /> - 系统设置(开发中)} /> + } /> diff --git a/apps/web/src/api/dictionaries.ts b/apps/web/src/api/dictionaries.ts new file mode 100644 index 0000000..b37ac15 --- /dev/null +++ b/apps/web/src/api/dictionaries.ts @@ -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 }>( + '/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; +} diff --git a/apps/web/src/api/menus.ts b/apps/web/src/api/menus.ts new file mode 100644 index 0000000..ef5cc4f --- /dev/null +++ b/apps/web/src/api/menus.ts @@ -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 }); +} diff --git a/apps/web/src/api/numberingRules.ts b/apps/web/src/api/numberingRules.ts new file mode 100644 index 0000000..2f1acb7 --- /dev/null +++ b/apps/web/src/api/numberingRules.ts @@ -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 }>( + '/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; +} diff --git a/apps/web/src/api/settings.ts b/apps/web/src/api/settings.ts new file mode 100644 index 0000000..59abef6 --- /dev/null +++ b/apps/web/src/api/settings.ts @@ -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; +} diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx new file mode 100644 index 0000000..ed3c27e --- /dev/null +++ b/apps/web/src/pages/Settings.tsx @@ -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: }, + { key: 'menus', label: '菜单配置', children: }, + { key: 'numbering', label: '编号规则', children: }, + { key: 'settings', label: '系统参数', children: }, + { key: 'theme', label: '主题设置', children: }, + ]; + + return ; +}; + +export default Settings; diff --git a/apps/web/src/pages/settings/DictionaryManager.tsx b/apps/web/src/pages/settings/DictionaryManager.tsx new file mode 100644 index 0000000..dff4302 --- /dev/null +++ b/apps/web/src/pages/settings/DictionaryManager.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [dictModalOpen, setDictModalOpen] = useState(false); + const [editDict, setEditDict] = useState(null); + const [itemModalOpen, setItemModalOpen] = useState(false); + const [activeDictId, setActiveDictId] = useState(null); + const [editItem, setEditItem] = useState(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) => ( + + + + handleDeleteDict(record.id)} + > + + + + ), + }, + ]; + + 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 ? {color} : '-', + }, + { + title: '操作', + key: 'actions', + render: (_: unknown, record: DictItem) => ( + + + handleDeleteItem(dictId, record.id)} + > + + + + ), + }, + ]; + + return ( +
+
+ + 数据字典管理 + + +
+ + ( +
+ ), + }} + /> + + {/* Dictionary Modal */} + dictForm.submit()} + > +
+ + + + + + + + + + +
+ + {/* Dictionary Item Modal */} + itemForm.submit()} + > +
+ + + + + + + + + + + + + +
+ + ); +} diff --git a/apps/web/src/pages/settings/MenuConfig.tsx b/apps/web/src/pages/settings/MenuConfig.tsx new file mode 100644 index 0000000..15fcafa --- /dev/null +++ b/apps/web/src/pages/settings/MenuConfig.tsx @@ -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(); + 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 = { + directory: { text: '目录', color: 'blue' }, + menu: { text: '菜单', color: 'green' }, + button: { text: '按钮', color: 'orange' }, +}; + +// --- Component --- + +export default function MenuConfig() { + const [menus, setMenus] = useState([]); + const [menuTree, setMenuTree] = useState([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editMenu, setEditMenu] = useState(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 {info.text}; + }, + }, + { + title: '排序', + dataIndex: 'sort_order', + key: 'sort_order', + width: 80, + }, + { + title: '可见', + dataIndex: 'visible', + key: 'visible', + width: 80, + render: (v: boolean) => + v ? : , + }, + { + title: '操作', + key: 'actions', + width: 150, + render: (_: unknown, record: MenuItem) => ( + + + handleDelete(record.id)} + > + + + + ), + }, + ]; + + return ( +
+
+ + 菜单配置 + + +
+ +
+ + form.submit()} + width={560} + > +
+ + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/apps/web/src/pages/settings/NumberingRules.tsx b/apps/web/src/pages/settings/NumberingRules.tsx new file mode 100644 index 0000000..15b5c0b --- /dev/null +++ b/apps/web/src/pages/settings/NumberingRules.tsx @@ -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 = { + never: '不重置', + daily: '每天', + monthly: '每月', + yearly: '每年', +}; + +// --- Component --- + +export default function NumberingRules() { + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editRule, setEditRule] = useState(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) => ( + + + + handleDelete(record.id)} + > + + + + ), + }, + ]; + + return ( +
+
+ + 编号规则管理 + + +
+ +
+ + form.submit()} + width={560} + > +
+ + + + + + + + + + + + + + + + + + + + + + + setSearchKey(e.target.value)} + onPressEnter={handleSearch} + style={{ width: 300 }} + /> + + + +
+ + form.submit()} + width={560} + > + + + + + + + + + + + ); +} diff --git a/apps/web/src/pages/settings/ThemeSettings.tsx b/apps/web/src/pages/settings/ThemeSettings.tsx new file mode 100644 index 0000000..ef4df11 --- /dev/null +++ b/apps/web/src/pages/settings/ThemeSettings.tsx @@ -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 ( +
+ + 主题设置 + + +
+ + + + + + + +