Compare commits

...

45 Commits

Author SHA1 Message Date
iven
ff352a4c24 feat(plugin): 集成 WASM 插件系统到主服务并修复链路问题
- 新增 erp-plugin crate:插件管理、WASM 运行时、动态表、数据 CRUD
- 新增前端插件管理页面(PluginAdmin/PluginCRUDPage)和 API 层
- 新增插件数据迁移(plugins/plugin_entities/plugin_event_subscriptions)
- 新增权限补充迁移(为已有租户补充 plugin.admin/plugin.list 权限)
- 修复 PluginAdmin 页面 InstallOutlined 图标不存在的崩溃问题
- 修复 settings 唯一索引迁移顺序错误(先去重再建索引)
- 更新 wiki 和 CLAUDE.md 反映插件系统集成状态
- 新增 dev.ps1 一键启动脚本
2026-04-15 23:32:02 +08:00
iven
7e8fabb095 feat(auth): add change password API and frontend page
Backend:
- Add ChangePasswordReq DTO with validation (current + new password)
- Add AuthService::change_password() method with credential verification,
  password rehash, and token revocation
- Add POST /api/v1/auth/change-password endpoint with utoipa annotation

Frontend:
- Add changePassword() API function in auth.ts
- Add ChangePassword.tsx page with form validation and confirmation
- Add "修改密码" tab in Settings page

After password change, all refresh tokens are revoked and the user
is redirected to the login page.
2026-04-15 01:32:18 +08:00
iven
d8a0ac7519 feat: implement on_tenant_created/deleted hooks and update ErpModule trait
- ErpModule trait hooks now accept db and event_bus parameters
- AuthModule.on_tenant_created: seeds default roles, permissions,
  and admin user for new tenants using existing seed_tenant_auth()
- AuthModule.on_tenant_deleted: soft-deletes all users for the tenant
- Updated all other modules (config, workflow, message) to match
  the new trait signature
2026-04-15 01:27:33 +08:00
iven
e44d6063be feat: add utoipa path annotations to all API handlers and wire OpenAPI spec
- Add #[utoipa::path] annotations to all 70+ handler functions across
  auth, config, workflow, and message modules
- Add IntoParams/ToSchema derives to Pagination, PaginatedResponse, ApiResponse
  in erp-core, and MessageQuery/TemplateQuery in erp-message
- Collect all module paths into OpenAPI spec via AuthApiDoc, ConfigApiDoc,
  WorkflowApiDoc, MessageApiDoc structs in erp-server main.rs
- Update openapi_spec handler to merge all module specs
- The /docs/openapi.json endpoint now returns complete API documentation
  with all endpoints, request/response schemas, and security requirements
2026-04-15 01:23:27 +08:00
iven
ee65b6e3c9 test: add 149 unit tests across core, auth, config, message crates
Test coverage increased from ~34 to 183 tests (zero failures):

- erp-core (21): version check, pagination, API response, error mapping
- erp-auth (39): org tree building, DTO validation, error conversion,
  password hashing, user model mapping
- erp-config (57): DTO validation, numbering reset logic, menu tree
  building, error conversion. Fixed BatchSaveMenusReq nested validation
- erp-message (50): DTO validation, template rendering, query defaults,
  error conversion
- erp-workflow (16): unchanged (parser + expression tests)

All tests are pure unit tests requiring no database.
2026-04-15 01:06:34 +08:00
iven
9568dd7875 chore: apply cargo fmt across workspace and update docs
- Run cargo fmt on all Rust crates for consistent formatting
- Update CLAUDE.md with WASM plugin commands and dev.ps1 instructions
- Update wiki: add WASM plugin architecture, rewrite dev environment docs
- Minor frontend cleanup (unused imports)
2026-04-15 00:49:20 +08:00
iven
e16c1a85d7 feat(web): comprehensive frontend performance and UI/UX optimization
Performance improvements:
- Vite build: manual chunks, terser minification, optimizeDeps
- API response caching with 5s TTL via axios interceptors
- React.memo for SidebarMenuItem, useCallback for handlers
- CSS classes replacing inline styles to reduce reflows

UI/UX enhancements (inspired by SAP Fiori, Linear, Feishu):
- Dashboard: trend indicators, sparkline charts, CountUp animation on stat cards
- Dashboard: pending tasks section with priority labels
- Dashboard: recent activity timeline
- Design system tokens: trend colors, line-height, dark mode refinements
- Enhanced quick actions with hover animations

Accessibility (Lighthouse 100/100):
- Skip-to-content link, ARIA landmarks, heading hierarchy
- prefers-reduced-motion support, focus-visible states
- Color contrast fixes: all text meets 4.5:1 ratio
- Keyboard navigation for stat cards and task items

SEO: meta theme-color, format-detection, robots.txt
2026-04-13 01:37:55 +08:00
iven
88f6516fa9 fix(web): fix PaletteOutlined icon import and apply theme config
- Replace non-existent PaletteOutlined with BgColorsOutlined
- Apply user's refined light/dark theme configuration with proper
  color tokens, component overrides, and design system consistency
2026-04-12 18:57:10 +08:00
iven
9557c9ca16 fix(db): resolve migration bugs preventing fresh database initialization
- Fix composite primary keys in role_permissions and user_roles tables
  (PostgreSQL does not allow multiple PRIMARY KEY constraints)
- Fix FK table name mismatch: tasks → tokens (was wf_tokens)
- Fix FK table name mismatch: messages → message_templates (was message_templates_ref)
- Fix tenant table name in main.rs SQL: tenant (not tenants)
- Fix React Router nested routes: add /* wildcard for child route matching
2026-04-12 16:58:47 +08:00
iven
3b41e73f82 fix: resolve E2E audit findings and add Phase C frontend pages
- Fix audit_log handler multi-tenant bug: use Extension<TenantContext>
  instead of hardcoded default_tenant_id
- Fix sendMessage route mismatch: frontend /messages/send → /messages
- Add POST /users/{id}/roles backend route for role assignment
- Add task.completed event payload: started_by + instance_id for
  notification delivery
- Add audit log viewer frontend page (AuditLogViewer.tsx)
- Add language management frontend page (LanguageManager.tsx)
- Add api/auditLogs.ts and api/languages.ts modules
2026-04-12 15:57:33 +08:00
iven
14f431efff feat: systematic functional audit — fix 18 issues across Phase A/B
Phase A (P1 production blockers):
- A1: Apply IP rate limiting to public routes (login/refresh)
- A2: Publish domain events for workflow instance state transitions
  (completed/suspended/resumed/terminated) via outbox pattern
- A3: Replace hardcoded nil UUID default tenant with dynamic DB lookup
- A4: Add GET /api/v1/audit-logs query endpoint with pagination
- A5: Enhance CORS wildcard warning for production environments

Phase B (P2 functional gaps):
- B1: Remove dead erp-common crate (zero references in codebase)
- B2: Refactor 5 settings pages to use typed API modules instead of
  direct client calls; create api/themes.ts; delete dead errors.ts
- B3: Add resume/suspend buttons to InstanceMonitor page
- B4: Remove unused EventHandler trait from erp-core
- B5: Handle task.completed events in message module (send notifications)
- B6: Wire TimeoutChecker as 60s background task
- B7: Auto-skip ServiceTask nodes instead of crashing the process
- B8: Remove empty register_routes() from ErpModule trait and modules
2026-04-12 15:22:28 +08:00
iven
685df5e458 feat(core): implement event outbox persistence
Add domain_events migration and SeaORM entity. Modify EventBus::publish
to persist events before broadcasting (best-effort: DB failure logs
warning but still broadcasts in-memory). Update all 19 publish call
sites across 4 crates to pass db reference.

Add outbox relay background task that polls pending events every 5s
and re-broadcasts them, ensuring no events are lost on server restart.
2026-04-12 00:10:49 +08:00
iven
529d90ff46 feat(server): add Redis-based rate limiting middleware
Store Redis client in AppState instead of discarding it. Create
rate_limit middleware using Redis INCR + EXPIRE for fixed-window
counting. Apply user-based rate limiting (100 req/min) to all
protected routes. Graceful degradation when Redis is unavailable.
2026-04-11 23:58:54 +08:00
iven
db2cd24259 feat(core): add audit logging to all mutation operations
Create audit_log SeaORM entity and audit_service::record() helper.
Integrate audit recording into 35 mutation endpoints across all modules:
- erp-auth: user/role/organization/department/position CRUD (15 actions)
- erp-config: dictionary/menu/setting/numbering_rule CRUD (15 actions)
- erp-workflow: definition/instance/task operations (8 actions)
- erp-message: send/system/mark_read/delete (5 actions)

Uses fire-and-forget pattern — audit failures logged but non-blocking.
2026-04-11 23:48:45 +08:00
iven
5d6e1dc394 feat(core): implement optimistic locking across all entities
Add VersionMismatch error variant and check_version() helper to erp-core.
All 13 mutable entities now enforce version checking on update/delete:
- erp-auth: user, role, organization, department, position
- erp-config: dictionary, dictionary_item, menu, setting, numbering_rule
- erp-workflow: process_definition, process_instance, task
- erp-message: message, message_subscription

Update DTOs to expose version in responses and require version in update
requests. HTTP 409 Conflict returned on version mismatch.
2026-04-11 23:25:43 +08:00
iven
1fec5e2cf2 feat(web): wire ProcessViewer and delegate UI into workflow pages
- InstanceMonitor: add '流程图' button that opens ProcessViewer modal
  with active node highlighting, loading flow definition via API
- PendingTasks: add '委派' button with delegate modal (UUID input),
  wired to the existing delegateTask API function
- Both ProcessViewer component and delegateTask API were previously
  dead code (never imported/called)
2026-04-11 16:26:47 +08:00
iven
6a08b99ed8 fix(workflow): reject ServiceTask at runtime instead of silently stalling
ServiceTask was accepted by the parser but fell through to the
wildcard branch in the executor, creating an active token that
never progresses. Now returns a clear error so users know the
feature is not yet implemented rather than debugging a stuck flow.
2026-04-11 16:22:32 +08:00
iven
96a4287272 docs(workflow): clarify version vs version_field naming in process_definition
Add doc comments to distinguish the business version field (key-based
revision counter, currently fixed at 1) from the optimistic lock field
(version_field). Renaming requires a DB migration so deferred to later.
2026-04-11 16:20:32 +08:00
iven
d8c3aba5d6 fix(workflow): add tenant membership check to task delegation
The delegate method was accepting any UUID as delegate_to without
verifying the target user belongs to the same tenant. This allowed
cross-tenant task delegation. Added raw SQL check against users table
to avoid cross-module dependency on erp-auth.
2026-04-11 16:18:24 +08:00
iven
c02fcecbfc feat(web): wire Home dashboard to real API data
Replace hardcoded placeholder values with live data fetched from
/users, /roles, /workflow/instances, and message store unread count.
Uses Promise.allSettled for resilient parallel loading.
2026-04-11 14:37:52 +08:00
iven
4bfd9573db fix(config): add individual menu CRUD endpoints and fix frontend menu data handling
- Add POST /config/menus (create single menu)
- Add PUT /config/menus/{id} (update single menu)
- Add DELETE /config/menus/{id} (soft delete single menu)
- Frontend MenuConfig was calling individual CRUD routes that didn't exist,
  causing 404 errors on all create/update/delete operations
- Fix frontend to correctly handle nested tree response from backend
2026-04-11 14:34:48 +08:00
iven
0d7d3af0a8 fix(auth): standardize permission codes to dot notation and add missing permissions
- Change all permission codes from colon (`:`) to dot (`.`) separator
  to match handler require_permission() calls consistently
- Add missing user.list, role.list, permission.list, organization.list,
  department.list, position.list permissions (handlers check for .list
  but seeds only had :read)
- Add missing message module permissions (message.list, message.send,
  message.template.list, message.template.create)
- Add missing setting.delete, numbering.delete permissions
- Fix workflow handlers: workflow: → workflow.
- Fix message handlers: message: → message.
- Update viewer role READ_PERM_INDICES for new permission list

This fixes a critical runtime bug where ALL permission checks in
erp-auth and erp-config handlers would return 403 Forbidden because
the seed data used colon separators but handlers checked for dots.
2026-04-11 14:30:47 +08:00
iven
f29f6d76ee fix(message): resolve Phase 5-6 audit findings
- Add missing version column to all message tables (migration + entities)
- Replace N+1 mark_all_read loop with single batch UPDATE query
- Fix NotificationList infinite re-render (extract queryFilter to stable ref)
- Fix NotificationPreferences dynamic import and remove unused Dayjs type
- Add Semaphore (max 8) to event listener for backpressure control
- Add /docs/openapi.json endpoint for API documentation
- Add permission check to unread_count handler
- Add version: Set(1) to all ActiveModel inserts
2026-04-11 14:16:45 +08:00
iven
97d3c9026b fix: resolve remaining clippy warnings and improve workflow frontend
- Collapse nested if-let in user_service.rs search filter
- Suppress dead_code warning on ApiDoc struct
- Refactor server routing: nest all routes under /api/v1 prefix
- Simplify health check route path
- Improve workflow ProcessDesigner with edit mode and loading states
- Update workflow pages with enhanced UX
- Add Phase 2 implementation plan document
2026-04-11 12:59:43 +08:00
iven
184034ff6b feat(config): add missing dictionary item CRUD, setting delete, and numbering delete routes
- Dictionary items: POST/PUT/DELETE endpoints under /config/dictionaries/{dict_id}/items
- Settings: DELETE /config/settings/{key}
- Numbering rules: DELETE /config/numbering-rules/{id}
- Fix workflow Entities: add deleted_at and version_field to process_definition,
  add standard fields to token and process_variable entities
- Update seed data for expanded permissions
2026-04-11 12:52:29 +08:00
iven
82986e988d docs: update progress to reflect Phase 1-6 completion
- Update CLAUDE.md architecture snapshot: all phases complete
- Update wiki/index.md: module descriptions and progress table
- All 6 phases of ERP platform base are now implemented

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 12:51:17 +08:00
iven
b3c7f76b7f fix(security): resolve audit findings and compilation errors (Phase 6)
Security fixes:
- Add startup warning for default JWT secret in config
- Add enum validation for priority, recipient_type, channel fields
- Add pagination size cap (max 100) via safe_page_size()
- Return generic "权限不足" instead of specific permission names

Compilation fixes:
- Fix missing standard fields in ActiveModel for tokens/process_variables
- Fix migration imports for Statement/DatabaseBackend/Uuid
- Add version_field to process_definition ActiveModel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 12:49:45 +08:00
iven
3a05523d23 fix: address Phase 1-2 audit findings
- CORS: replace permissive() with configurable whitelist (default.toml)
- Auth store: synchronously restore state at creation to eliminate
  flash-of-login-page on refresh
- MainLayout: menu highlight now tracks current route via useLocation
- Add extractErrorMessage() utility to reduce repeated error parsing
- Fix all clippy warnings across 4 crates (erp-auth, erp-config,
  erp-workflow, erp-message): remove unnecessary casts, use div_ceil,
  collapse nested ifs, reduce function arguments with DTOs
2026-04-11 12:36:34 +08:00
iven
5c899e6f4a feat(server): add OpenAPI JSON endpoint for API documentation (Phase 6)
Add /api/docs/openapi.json public endpoint returning the OpenAPI spec.
Uses utoipa derive macro with basic API metadata.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 12:33:48 +08:00
iven
bddd33ac2f feat(core): add audit log infrastructure (Phase 6)
- Add audit_logs database migration with indexes
- Add AuditLog struct in erp-core with builder pattern
- Support old/new value tracking, IP address, user agent

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 12:31:27 +08:00
iven
c0523e19b4 feat(message): add cross-module event integration (Phase 6)
- Message module subscribes to workflow events (process_instance.started)
- Auto-generates notifications when workflows start
- Added started_by to workflow instance event payload
- Event listener runs as background tokio task

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 12:28:13 +08:00
iven
5ceed71e62 feat(message): add message center module (Phase 5)
Implement the complete message center with:
- Database migrations for message_templates, messages, message_subscriptions tables
- erp-message crate with entities, DTOs, services, handlers
- Message CRUD, send, read/unread tracking, soft delete
- Template management with variable interpolation
- Subscription preferences with DND support
- Frontend: messages page, notification panel, unread count badge
- Server integration with module registration and routing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 12:25:05 +08:00
iven
91ecaa3ed7 feat(workflow): add workflow engine module (Phase 4)
Implement complete workflow engine with BPMN subset support:

Backend (erp-workflow crate):
- Token-driven execution engine with exclusive/parallel gateway support
- BPMN parser with flow graph validation
- Expression evaluator for conditional branching
- Process definition CRUD with draft/publish lifecycle
- Process instance management (start, suspend, terminate)
- Task service (pending, complete, delegate)
- PostgreSQL advisory locks for concurrent safety
- 5 database tables: process_definitions, process_instances,
  tokens, tasks, process_variables
- 13 API endpoints with RBAC protection
- Timeout checker framework (placeholder)

Frontend:
- Workflow page with 4 tabs (definitions, pending, completed, monitor)
- React Flow visual process designer (@xyflow/react)
- Process viewer with active node highlighting
- 3 API client modules for workflow endpoints
- Sidebar menu integration
2026-04-11 09:54:02 +08:00
iven
0cbd08eb78 fix(config): resolve critical audit findings from Phase 1-3 review
- C-1: Add tenant_id to settings unique index to prevent cross-tenant conflicts
- C-2: Move pg_advisory_xact_lock inside the transaction for correct concurrency
  (previously lock was released before the numbering transaction started)
- H-5: Add CORS middleware (permissive for dev, TODO: restrict in production)
2026-04-11 08:26:43 +08:00
iven
0baaf5f7ee 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.
2026-04-11 08:09:19 +08:00
iven
8a012f6c6a feat(auth): add org/dept/position management, user page, and Phase 2 completion
Complete Phase 2 identity & authentication module:
- Organization CRUD with tree structure (parent_id + materialized path)
- Department CRUD nested under organizations with tree support
- Position CRUD nested under departments
- User management page with table, create/edit modal, role assignment
- Organization architecture page with 3-panel tree layout
- Frontend API layer for orgs/depts/positions
- Sidebar navigation updated with organization menu item
- Fix parse_ttl edge case for strings ending in 'd' (e.g. "invalid")
2026-04-11 04:00:32 +08:00
iven
6fd0288e7c feat(auth): add role/permission management (backend + frontend)
- RoleService: CRUD, assign_permissions, get_role_permissions
- PermissionService: list all tenant permissions
- Role handlers: 8 endpoints with RBAC permission checks
- Frontend Roles page: table, create/edit modal, permission assignment
- Frontend Roles API: full CRUD + permission operations
- Routes registered in AuthModule protected_routes
2026-04-11 03:46:54 +08:00
iven
4a03a639a6 feat(web): add login page, auth store, API client, and route guard
- API client with axios interceptors: JWT attach + 401 auto-refresh
- Auth store (Zustand): login/logout/loadFromStorage with localStorage
- Login page: gradient background, Ant Design form, error handling
- Home page: dashboard with statistics cards
- App.tsx: PrivateRoute guard, /login route, auth state restoration
- MainLayout: dynamic user display, logout dropdown, menu navigation
- Users API service: CRUD with pagination support
2026-04-11 03:38:29 +08:00
iven
a7cdf67d17 feat(auth): add tenant seed data and bootstrap logic
- seed.rs: creates 21 permissions, admin+viewer roles, admin user with Argon2 password
- AuthConfig added to server config with default password Admin@2026
- Server startup: auto-creates default tenant and seeds auth data if not exists
- Idempotent: checks for existing tenant before seeding
2026-04-11 03:28:19 +08:00
iven
3afd732de8 feat(auth): add handlers, JWT middleware, RBAC, and module registration
- Auth handlers: login/refresh/logout + user CRUD with tenant isolation
- JWT middleware: Bearer token validation → TenantContext injection
- RBAC helpers: require_permission, require_any_permission, require_role
- AuthModule: implements ErpModule with public/protected route split
- AuthState: FromRef pattern avoids circular deps between erp-auth and erp-server
- Server: public routes (health+login+refresh) + protected routes (JWT middleware)
- ErpModule trait: added as_any() for downcast support
- Workspace: added async-trait, sha2 dependencies
2026-04-11 03:22:04 +08:00
iven
edc41a1500 feat(auth): implement core service layer (password, JWT, auth, user CRUD)
- error.rs: AuthError with proper HTTP status mapping
- service/password.rs: Argon2 hash/verify with tests
- service/token_service.rs: JWT sign/validate, token DB storage with SHA-256 hash
- service/auth_service.rs: login/refresh/logout flows with event publishing
- service/user_service.rs: user CRUD with soft delete and tenant isolation
- Added sha2 dependency to workspace for token hashing
2026-04-11 03:05:17 +08:00
iven
411a07caa1 feat(auth): add SeaORM entities and DTOs for auth module
- 11 entity files mapping to all auth migration tables
- DTOs with validation: LoginReq, CreateUserReq, CreateRoleReq, etc.
- Response DTOs: UserResp, RoleResp, PermissionResp, OrganizationResp, etc.
- Added workspace dependencies: jsonwebtoken, argon2, validator, thiserror, utoipa
2026-04-11 02:53:41 +08:00
iven
d98e0d383c feat(db): add auth schema migrations (10 tables)
- users with partial unique index on (tenant_id, username) WHERE deleted_at IS NULL
- user_credentials, user_tokens with FK cascade
- roles, permissions with composite unique (tenant_id, code)
- role_permissions, user_roles junction tables
- organizations (self-ref tree), departments (tree + org FK), positions
- All tables include standard fields: id, tenant_id, timestamps, soft delete, version
2026-04-11 02:03:23 +08:00
iven
810eef769f feat(server): integrate AppState, ModuleRegistry, health check, and graceful shutdown
- Add AppState with DB, Config, EventBus, ModuleRegistry via Axum State
- ModuleRegistry now uses Arc for Clone support, builder-pattern register()
- Add /api/v1/health endpoint returning status, version, registered modules
- Add graceful shutdown on CTRL+C / SIGTERM
- erp-common utils: ID generation, timestamp helpers, code generator with tests
- Config structs now derive Clone for state sharing
- Update wiki to reflect Phase 1 completion
2026-04-11 01:19:30 +08:00
iven
5901ee82f0 feat: complete Phase 1 infrastructure
- erp-core: error types, shared types, event bus, ErpModule trait
- erp-server: config loading, database/Redis connections, migrations
- erp-server/migration: tenants table with SeaORM
- apps/web: Vite + React 18 + TypeScript + Ant Design 5 + TailwindCSS
- Web frontend: main layout with sidebar, header, routing
- Docker: PostgreSQL 16 + Redis 7 development environment
- All workspace crates compile successfully (cargo check passes)
2026-04-11 01:07:31 +08:00
284 changed files with 45176 additions and 341 deletions

View File

@@ -1,3 +1,6 @@
@wiki/index.md
整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。
# ERP 平台底座 — 协作与实现规则
> **ERP Platform Base** 是一个模块化的商业 SaaS ERP 底座,目标是提供核心基础设施(身份权限、工作流、消息、配置),使行业业务模块(进销存、生产、财务等)可以快速插接。
@@ -369,6 +372,17 @@ cd apps/web && pnpm build # 构建生产版本
# === 数据库 ===
docker exec -it erp-postgres psql -U erp # 连接数据库
# === WASM 插件 ===
cargo build -p erp-plugin-test-sample --target wasm32-unknown-unknown --release # 编译测试插件
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_test_sample.wasm -o target/erp_plugin_test_sample.component.wasm # 转为 Component
cargo test -p erp-plugin-prototype # 运行插件集成测试
# === 一键启动 (PowerShell) ===
.\dev.ps1 # 启动前后端(自动清理端口占用)
.\dev.ps1 -Stop # 停止前后端
.\dev.ps1 -Restart # 重启前后端
.\dev.ps1 -Status # 查看端口状态
```
---
@@ -399,6 +413,7 @@ docker exec -it erp-postgres psql -U erp # 连接数据库
| `message` | erp-message |
| `config` | erp-config |
| `server` | erp-server |
| `plugin` | erp-plugin-prototype / erp-plugin-test-sample |
| `web` | Web 前端 |
| `ui` | React 组件 |
| `db` | 数据库迁移 |
@@ -422,6 +437,8 @@ chore(docker): 添加 PostgreSQL 健康检查
|------|------|
| `docs/superpowers/specs/2026-04-10-erp-platform-base-design.md` | 平台底座设计规格 |
| `docs/superpowers/plans/2026-04-10-erp-platform-base-plan.md` | 平台底座实施计划 |
| `docs/superpowers/specs/2026-04-13-wasm-plugin-system-design.md` | WASM 插件系统设计规格 |
| `docs/superpowers/plans/2026-04-13-wasm-plugin-system-plan.md` | WASM 插件原型验证计划 |
所有设计决策以设计规格文档为准。实施计划按阶段拆分,每阶段开始前细化。
@@ -436,24 +453,29 @@ chore(docker): 添加 PostgreSQL 健康检查
| Phase | 内容 | 状态 |
|-------|------|------|
| Phase 1 | 基础设施 (workspace + core + Docker + 桌面端) | 🚧 进行中 |
| Phase 2 | 身份与权限 (Auth) | ⏳ 待开始 |
| Phase 3 | 系统配置 (Config) | ⏳ 待开始 |
| Phase 4 | 工作流引擎 (Workflow) | ⏳ 待开始 |
| Phase 5 | 消息中心 (Message) | ⏳ 待开始 |
| Phase 6 | 整合与打磨 | ⏳ 待开始 |
| Phase 1 | 基础设施 (workspace + core + Docker + 桌面端) | ✅ 完成 |
| Phase 2 | 身份与权限 (Auth) | ✅ 完成 |
| Phase 3 | 系统配置 (Config) | ✅ 完成 |
| Phase 4 | 工作流引擎 (Workflow) | ✅ 完成 |
| Phase 5 | 消息中心 (Message) | ✅ 完成 |
| Phase 6 | 整合与打磨 | ✅ 完成 |
| - | WASM 插件原型 (V1-V6) | ✅ 验证通过 |
| - | 插件系统集成到主服务 | ✅ 已集成 |
### 已实现模块
| Crate | 功能 | 状态 |
|-------|------|------|
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait | 🚧 进行中 |
| erp-common | 共享工具 | 🚧 进行中 |
| erp-server | Axum 服务入口、配置、数据库连接 | 🚧 进行中 |
| erp-auth | 身份与权限 | ⏳ 待开始 |
| erp-workflow | 工作流引擎 | ⏳ 待开始 |
| erp-message | 消息中心 | ⏳ 待开始 |
| erp-config | 系统配置 | ⏳ 待开始 |
| erp-core | 错误类型、共享类型、事件总线、ErpModule trait、审计日志 | ✅ 完成 |
| erp-common | 共享工具 | ✅ 完成 |
| erp-server | Axum 服务入口、配置、数据库连接、CORS | ✅ 完成 |
| erp-auth | 身份与权限 (用户/角色/权限/组织/部门/岗位) | ✅ 完成 |
| erp-workflow | 工作流引擎 (BPMN 解析/Token 驱动/任务分配) | ✅ 完成 |
| erp-message | 消息中心 (CRUD/模板/订阅/通知面板) | ✅ 完成 |
| erp-config | 系统配置 (字典/菜单/设置/编号规则/主题) | ✅ 完成 |
| erp-plugin | 插件管理 (WASM 运行时/生命周期/动态表/数据CRUD) | ✅ 已集成 |
| erp-plugin-prototype | WASM 插件 Host 运行时 (Wasmtime + bindgen + Host API) | ✅ 原型验证 |
| erp-plugin-test-sample | WASM 测试插件 (Guest trait + Host API 回调) | ✅ 原型验证 |
<!-- ARCH-SNAPSHOT-END -->
@@ -481,5 +503,6 @@ chore(docker): 添加 PostgreSQL 健康检查
- 当遇到**新增 API** → 添加 utoipa 注解,确保 OpenAPI 文档同步
- 当遇到**新增表** → 创建 SeaORM migration + Entity包含所有标准字段
- 当遇到**新增页面** → 使用 Ant Design 组件i18n key 引用文案
- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component
<!-- ANTI-PATTERN-END -->

2213
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,12 +2,15 @@
resolver = "2"
members = [
"crates/erp-core",
"crates/erp-common",
"crates/erp-server",
"crates/erp-auth",
"crates/erp-workflow",
"crates/erp-message",
"crates/erp-config",
"crates/erp-server/migration",
"crates/erp-plugin-prototype",
"crates/erp-plugin-test-sample",
"crates/erp-plugin",
]
[workspace.package]
@@ -20,7 +23,7 @@ license = "MIT"
tokio = { version = "1", features = ["full"] }
# Web
axum = "0.8"
axum = { version = "0.8", features = ["multipart"] }
tower = "0.5"
tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip"] }
@@ -58,17 +61,24 @@ jsonwebtoken = "9"
# Password hashing
argon2 = "0.5"
# Cryptographic hashing (token storage)
sha2 = "0.10"
# API docs
utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
utoipa-swagger-ui = { version = "8", features = ["axum"] }
# utoipa-swagger-ui 需要下载 GitHub 资源,网络受限时暂不使用
# utoipa-swagger-ui = { version = "8", features = ["axum"] }
# Validation
validator = { version = "0.19", features = ["derive"] }
# Async trait
async-trait = "0.1"
# Internal crates
erp-core = { path = "crates/erp-core" }
erp-common = { path = "crates/erp-common" }
erp-auth = { path = "crates/erp-auth" }
erp-workflow = { path = "crates/erp-workflow" }
erp-message = { path = "crates/erp-message" }
erp-config = { path = "crates/erp-config" }
erp-plugin = { path = "crates/erp-plugin" }

64
README.md Normal file
View File

@@ -0,0 +1,64 @@
# ERP Platform Base
模块化商业 SaaS ERP 平台底座。
## 技术栈
| 层级 | 技术 |
|------|------|
| 后端 | Rust (Axum 0.8 + SeaORM + Tokio) |
| 数据库 | PostgreSQL 16+ |
| 缓存 | Redis 7+ |
| 前端 | Vite + React 18 + TypeScript + Ant Design 5 |
## 项目结构
```
erp/
├── crates/
│ ├── erp-core/ # 基础类型、错误、事件总线、模块 trait
│ ├── erp-common/ # 共享工具
│ ├── erp-auth/ # 身份与权限 (Phase 2)
│ ├── erp-workflow/ # 工作流引擎 (Phase 4)
│ ├── erp-message/ # 消息中心 (Phase 5)
│ ├── erp-config/ # 系统配置 (Phase 3)
│ └── erp-server/ # Axum 服务入口
│ └── migration/ # SeaORM 数据库迁移
├── apps/web/ # React SPA 前端
├── docker/ # Docker 开发环境
└── docs/ # 文档
```
## 快速开始
### 1. 启动基础设施
```bash
cd docker && docker compose up -d
```
### 2. 启动后端
```bash
cargo run -p erp-server
```
### 3. 启动前端
```bash
cd apps/web && pnpm install && pnpm dev
```
### 4. 访问
- 前端: http://localhost:5173
- 后端 API: http://localhost:3000
## 开发命令
```bash
cargo check # 编译检查
cargo test --workspace # 运行测试
cargo run -p erp-server # 启动后端
cd apps/web && pnpm dev # 启动前端
```

24
apps/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
apps/web/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
apps/web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
apps/web/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="ERP 平台底座 — 模块化 SaaS 企业资源管理系统,提供身份权限、工作流引擎、消息中心、系统配置等核心基础设施" />
<meta name="theme-color" content="#4F46E5" />
<meta name="format-detection" content="telephone=no" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>ERP Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

38
apps/web/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@xyflow/react": "^12.10.2",
"antd": "^6.3.5",
"axios": "^1.15.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

3362
apps/web/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
apps/web/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -0,0 +1,2 @@
User-agent: *
Allow: /

153
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,153 @@
import { useEffect, lazy, Suspense } from 'react';
import { HashRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider, theme as antdTheme, Spin } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import MainLayout from './layouts/MainLayout';
import Login from './pages/Login';
import { useAuthStore } from './stores/auth';
import { useAppStore } from './stores/app';
const Home = lazy(() => import('./pages/Home'));
const Users = lazy(() => import('./pages/Users'));
const Roles = lazy(() => import('./pages/Roles'));
const Organizations = lazy(() => import('./pages/Organizations'));
const Workflow = lazy(() => import('./pages/Workflow'));
const Messages = lazy(() => import('./pages/Messages'));
const Settings = lazy(() => import('./pages/Settings'));
const PluginAdmin = lazy(() => import('./pages/PluginAdmin'));
const PluginCRUDPage = lazy(() => import('./pages/PluginCRUDPage'));
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
}
const themeConfig = {
token: {
colorPrimary: '#4F46E5',
colorSuccess: '#059669',
colorWarning: '#D97706',
colorError: '#DC2626',
colorInfo: '#2563EB',
colorBgLayout: '#F1F5F9',
colorBgContainer: '#FFFFFF',
colorBgElevated: '#FFFFFF',
colorBorder: '#E2E8F0',
colorBorderSecondary: '#F1F5F9',
borderRadius: 8,
borderRadiusLG: 12,
borderRadiusSM: 6,
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', 'Hiragino Sans GB', 'Helvetica Neue', Helvetica, Arial, sans-serif",
fontSize: 14,
fontSizeHeading4: 20,
controlHeight: 36,
controlHeightLG: 40,
controlHeightSM: 28,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06)',
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.07)',
},
components: {
Button: {
primaryShadow: '0 1px 2px 0 rgba(79, 70, 229, 0.3)',
fontWeight: 500,
},
Card: {
paddingLG: 20,
},
Table: {
headerBg: '#F8FAFC',
headerColor: '#475569',
rowHoverBg: '#F5F3FF',
fontSize: 14,
},
Menu: {
itemBorderRadius: 8,
itemMarginInline: 8,
itemHeight: 40,
},
Modal: {
borderRadiusLG: 16,
},
Tag: {
borderRadiusSM: 6,
},
},
};
const darkThemeConfig = {
...themeConfig,
token: {
...themeConfig.token,
colorBgLayout: '#0B0F1A',
colorBgContainer: '#111827',
colorBgElevated: '#1E293B',
colorBorder: '#1E293B',
colorBorderSecondary: '#1E293B',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.3)',
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.4)',
},
components: {
...themeConfig.components,
Table: {
headerBg: '#1E293B',
headerColor: '#94A3B8',
rowHoverBg: '#1E293B',
},
},
};
export default function App() {
const loadFromStorage = useAuthStore((s) => s.loadFromStorage);
const themeMode = useAppStore((s) => s.theme);
useEffect(() => {
loadFromStorage();
}, [loadFromStorage]);
useEffect(() => {
document.documentElement.setAttribute('data-theme', themeMode);
}, [themeMode]);
const isDark = themeMode === 'dark';
return (
<>
<a href="#root" className="erp-skip-link"></a>
<ConfigProvider
locale={zhCN}
theme={{
...isDark ? darkThemeConfig : themeConfig,
algorithm: isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
}}
>
<HashRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/*"
element={
<PrivateRoute>
<MainLayout>
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}><Spin size="large" /></div>}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/users" element={<Users />} />
<Route path="/roles" element={<Roles />} />
<Route path="/organizations" element={<Organizations />} />
<Route path="/workflow" element={<Workflow />} />
<Route path="/messages" element={<Messages />} />
<Route path="/settings" element={<Settings />} />
<Route path="/plugins/admin" element={<PluginAdmin />} />
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
</Routes>
</Suspense>
</MainLayout>
</PrivateRoute>
}
/>
</Routes>
</HashRouter>
</ConfigProvider>
</>
);
}

View File

@@ -0,0 +1,31 @@
import client from './client';
import type { PaginatedResponse } from './users';
export interface AuditLogItem {
id: string;
tenant_id: string;
action: string;
resource_type: string;
resource_id: string;
user_id: string;
old_value?: string;
new_value?: string;
ip_address?: string;
user_agent?: string;
created_at: string;
}
export interface AuditLogQuery {
resource_type?: string;
user_id?: string;
page?: number;
page_size?: number;
}
export async function listAuditLogs(query: AuditLogQuery = {}) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<AuditLogItem> }>(
'/audit-logs',
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
);
return data.data;
}

63
apps/web/src/api/auth.ts Normal file
View File

@@ -0,0 +1,63 @@
import client from './client';
export interface LoginRequest {
username: string;
password: string;
}
export interface UserInfo {
id: string;
username: string;
email?: string;
phone?: string;
display_name?: string;
avatar_url?: string;
status: string;
roles: RoleInfo[];
version: number;
}
export interface RoleInfo {
id: string;
name: string;
code: string;
description?: string;
is_system: boolean;
}
export interface LoginResponse {
access_token: string;
refresh_token: string;
expires_in: number;
user: UserInfo;
}
export async function login(req: LoginRequest): Promise<LoginResponse> {
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
'/auth/login',
req
);
return data.data;
}
export async function refresh(refreshToken: string): Promise<LoginResponse> {
const { data } = await client.post<{ success: boolean; data: LoginResponse }>(
'/auth/refresh',
{ refresh_token: refreshToken }
);
return data.data;
}
export async function logout(): Promise<void> {
await client.post('/auth/logout');
}
export async function changePassword(
currentPassword: string,
newPassword: string
): Promise<void> {
await client.post('/auth/change-password', {
current_password: currentPassword,
new_password: newPassword,
});
}

129
apps/web/src/api/client.ts Normal file
View File

@@ -0,0 +1,129 @@
import axios from 'axios';
const client = axios.create({
baseURL: '/api/v1',
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
// 请求缓存:短时间内相同请求复用结果
interface CacheEntry {
data: unknown;
timestamp: number;
}
const requestCache = new Map<string, CacheEntry>();
const CACHE_TTL = 5000; // 5 秒缓存
function getCacheKey(config: { url?: string; params?: unknown; method?: string }): string {
return `${config.method || 'get'}:${config.url || ''}:${JSON.stringify(config.params || {})}`;
}
// Request interceptor: attach access token + cache
client.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// GET 请求检查缓存
if (config.method === 'get' && config.url) {
const key = getCacheKey(config);
const entry = requestCache.get(key);
if (entry && Date.now() - entry.timestamp < CACHE_TTL) {
const source = axios.CancelToken.source();
config.cancelToken = source.token;
// 通过适配器返回缓存数据
source.cancel(JSON.stringify({ __cached: true, data: entry.data }));
}
}
return config;
});
// 响应拦截器:缓存 GET 响应 + 自动刷新 token
client.interceptors.response.use(
(response) => {
// 缓存 GET 响应
if (response.config.method === 'get' && response.config.url) {
const key = getCacheKey(response.config);
requestCache.set(key, { data: response.data, timestamp: Date.now() });
}
return response;
},
async (error) => {
// 处理缓存命中
if (axios.isCancel(error)) {
const cached = JSON.parse(error.message || '{}');
if (cached.__cached) {
return { data: cached.data, status: 200, statusText: 'OK (cached)', headers: {}, config: {} };
}
}
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return client(originalRequest);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) throw new Error('No refresh token');
const { data } = await axios.post('/api/v1/auth/refresh', {
refresh_token: refreshToken,
});
const newAccessToken = data.data.access_token;
const newRefreshToken = data.data.refresh_token;
localStorage.setItem('access_token', newAccessToken);
localStorage.setItem('refresh_token', newRefreshToken);
processQueue(null, newAccessToken);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return client(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
window.location.hash = '#/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null) {
failedQueue.forEach(({ resolve, reject }) => {
if (token) resolve(token);
else reject(error);
});
failedQueue = [];
}
// 清除缓存(登录/登出时调用)
export function clearApiCache() {
requestCache.clear();
}
export default client;

View File

@@ -0,0 +1,107 @@
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;
}
export interface CreateDictionaryItemRequest {
label: string;
value: string;
sort_order?: number;
color?: string;
}
export interface UpdateDictionaryItemRequest {
label?: string;
value?: string;
sort_order?: number;
color?: string;
}
export async function createDictionaryItem(
dictionaryId: string,
req: CreateDictionaryItemRequest,
) {
const { data } = await client.post<{ success: boolean; data: DictionaryItemInfo }>(
`/config/dictionaries/${dictionaryId}/items`,
req,
);
return data.data;
}
export async function updateDictionaryItem(
dictionaryId: string,
itemId: string,
req: UpdateDictionaryItemRequest,
) {
const { data } = await client.put<{ success: boolean; data: DictionaryItemInfo }>(
`/config/dictionaries/${dictionaryId}/items/${itemId}`,
req,
);
return data.data;
}
export async function deleteDictionaryItem(dictionaryId: string, itemId: string) {
await client.delete(`/config/dictionaries/${dictionaryId}/items/${itemId}`);
}

View File

@@ -0,0 +1,36 @@
import client from './client';
// --- Types ---
export interface LanguageInfo {
code: string;
name: string;
enabled: boolean;
translations?: Record<string, string>;
}
export interface UpdateLanguageRequest {
name?: string;
enabled?: boolean;
translations?: Record<string, string>;
}
// --- API Functions ---
export async function listLanguages(): Promise<LanguageInfo[]> {
const { data } = await client.get<{ success: boolean; data: LanguageInfo[] }>(
'/config/languages',
);
return data.data;
}
export async function updateLanguage(
code: string,
req: UpdateLanguageRequest,
): Promise<LanguageInfo> {
const { data } = await client.put<{ success: boolean; data: LanguageInfo }>(
`/config/languages/${code}`,
req,
);
return data.data;
}

56
apps/web/src/api/menus.ts Normal file
View File

@@ -0,0 +1,56 @@
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 });
}
export async function createMenu(req: MenuItemReq) {
const { data } = await client.post<{ success: boolean; data: MenuInfo }>(
'/config/menus',
req,
);
return data.data;
}
export async function updateMenu(id: string, req: MenuItemReq) {
const { data } = await client.put<{ success: boolean; data: MenuInfo }>(
`/config/menus/${id}`,
req,
);
return data.data;
}
export async function deleteMenu(id: string) {
await client.delete(`/config/menus/${id}`);
}

View File

@@ -0,0 +1,40 @@
import client from './client';
import type { PaginatedResponse } from './users';
export interface MessageTemplateInfo {
id: string;
tenant_id: string;
name: string;
code: string;
channel: string;
title_template: string;
body_template: string;
language: string;
created_at: string;
updated_at: string;
}
export interface CreateTemplateRequest {
name: string;
code: string;
channel?: string;
title_template: string;
body_template: string;
language?: string;
}
export async function listTemplates(page = 1, pageSize = 20) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MessageTemplateInfo> }>(
'/message-templates',
{ params: { page, page_size: pageSize } },
);
return data.data;
}
export async function createTemplate(req: CreateTemplateRequest) {
const { data } = await client.post<{ success: boolean; data: MessageTemplateInfo }>(
'/message-templates',
req,
);
return data.data;
}

View File

@@ -0,0 +1,88 @@
import client from './client';
import type { PaginatedResponse } from './users';
export interface MessageInfo {
id: string;
tenant_id: string;
template_id?: string;
sender_id?: string;
sender_type: string;
recipient_id: string;
recipient_type: string;
title: string;
body: string;
priority: string;
business_type?: string;
business_id?: string;
is_read: boolean;
read_at?: string;
is_archived: boolean;
status: string;
sent_at?: string;
created_at: string;
updated_at: string;
}
export interface SendMessageRequest {
title: string;
body: string;
recipient_id: string;
recipient_type?: string;
priority?: string;
template_id?: string;
business_type?: string;
business_id?: string;
}
export interface MessageQuery {
page?: number;
page_size?: number;
is_read?: boolean;
priority?: string;
business_type?: string;
status?: string;
}
export async function listMessages(query: MessageQuery = {}) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<MessageInfo> }>(
'/messages',
{ params: { page: query.page ?? 1, page_size: query.page_size ?? 20, ...query } },
);
return data.data;
}
export async function getUnreadCount() {
const { data } = await client.get<{ success: boolean; data: { count: number } }>(
'/messages/unread-count',
);
return data.data;
}
export async function markRead(id: string) {
const { data } = await client.put<{ success: boolean }>(
`/messages/${id}/read`,
);
return data;
}
export async function markAllRead() {
const { data } = await client.put<{ success: boolean }>(
'/messages/read-all',
);
return data;
}
export async function deleteMessage(id: string) {
const { data } = await client.delete<{ success: boolean }>(
`/messages/${id}`,
);
return data;
}
export async function sendMessage(req: SendMessageRequest) {
const { data } = await client.post<{ success: boolean; data: MessageInfo }>(
'/messages',
req,
);
return data.data;
}

View File

@@ -0,0 +1,71 @@
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;
}
export async function deleteNumberingRule(id: string) {
await client.delete(`/config/numbering-rules/${id}`);
}

174
apps/web/src/api/orgs.ts Normal file
View File

@@ -0,0 +1,174 @@
import client from './client';
// --- Organization types ---
export interface OrganizationInfo {
id: string;
name: string;
code?: string;
parent_id?: string;
path?: string;
level: number;
sort_order: number;
children: OrganizationInfo[];
version: number;
}
export interface CreateOrganizationRequest {
name: string;
code?: string;
parent_id?: string;
sort_order?: number;
}
export interface UpdateOrganizationRequest {
name?: string;
code?: string;
sort_order?: number;
version: number;
}
// --- Department types ---
export interface DepartmentInfo {
id: string;
org_id: string;
name: string;
code?: string;
parent_id?: string;
manager_id?: string;
path?: string;
sort_order: number;
children: DepartmentInfo[];
version: number;
}
export interface CreateDepartmentRequest {
name: string;
code?: string;
parent_id?: string;
manager_id?: string;
sort_order?: number;
}
export interface UpdateDepartmentRequest {
name?: string;
code?: string;
manager_id?: string;
sort_order?: number;
version: number;
}
// --- Position types ---
export interface PositionInfo {
id: string;
dept_id: string;
name: string;
code?: string;
level: number;
sort_order: number;
version: number;
}
export interface CreatePositionRequest {
name: string;
code?: string;
level?: number;
sort_order?: number;
}
export interface UpdatePositionRequest {
name?: string;
code?: string;
level?: number;
sort_order?: number;
version: number;
}
// --- Organization API ---
export async function listOrgTree() {
const { data } = await client.get<{ success: boolean; data: OrganizationInfo[] }>(
'/organizations',
);
return data.data;
}
export async function createOrg(req: CreateOrganizationRequest) {
const { data } = await client.post<{ success: boolean; data: OrganizationInfo }>(
'/organizations',
req,
);
return data.data;
}
export async function updateOrg(id: string, req: UpdateOrganizationRequest) {
const { data } = await client.put<{ success: boolean; data: OrganizationInfo }>(
`/organizations/${id}`,
req,
);
return data.data;
}
export async function deleteOrg(id: string) {
await client.delete(`/organizations/${id}`);
}
// --- Department API ---
export async function listDeptTree(orgId: string) {
const { data } = await client.get<{ success: boolean; data: DepartmentInfo[] }>(
`/organizations/${orgId}/departments`,
);
return data.data;
}
export async function createDept(orgId: string, req: CreateDepartmentRequest) {
const { data } = await client.post<{ success: boolean; data: DepartmentInfo }>(
`/organizations/${orgId}/departments`,
req,
);
return data.data;
}
export async function deleteDept(id: string) {
await client.delete(`/departments/${id}`);
}
export async function updateDept(id: string, req: UpdateDepartmentRequest) {
const { data } = await client.put<{ success: boolean; data: DepartmentInfo }>(
`/departments/${id}`,
req,
);
return data.data;
}
// --- Position API ---
export async function listPositions(deptId: string) {
const { data } = await client.get<{ success: boolean; data: PositionInfo[] }>(
`/departments/${deptId}/positions`,
);
return data.data;
}
export async function createPosition(deptId: string, req: CreatePositionRequest) {
const { data } = await client.post<{ success: boolean; data: PositionInfo }>(
`/departments/${deptId}/positions`,
req,
);
return data.data;
}
export async function deletePosition(id: string) {
await client.delete(`/positions/${id}`);
}
export async function updatePosition(id: string, req: UpdatePositionRequest) {
const { data } = await client.put<{ success: boolean; data: PositionInfo }>(
`/positions/${id}`,
req,
);
return data.data;
}

View File

@@ -0,0 +1,71 @@
import client from './client';
export interface PluginDataRecord {
id: string;
data: Record<string, unknown>;
created_at?: string;
updated_at?: string;
version?: number;
}
interface PaginatedDataResponse {
data: PluginDataRecord[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export async function listPluginData(
pluginId: string,
entity: string,
page = 1,
pageSize = 20,
) {
const { data } = await client.get<{ success: boolean; data: PaginatedDataResponse }>(
`/plugins/${pluginId}/${entity}`,
{ params: { page, page_size: pageSize } },
);
return data.data;
}
export async function getPluginData(pluginId: string, entity: string, id: string) {
const { data } = await client.get<{ success: boolean; data: PluginDataRecord }>(
`/plugins/${pluginId}/${entity}/${id}`,
);
return data.data;
}
export async function createPluginData(
pluginId: string,
entity: string,
recordData: Record<string, unknown>,
) {
const { data } = await client.post<{ success: boolean; data: PluginDataRecord }>(
`/plugins/${pluginId}/${entity}`,
{ data: recordData },
);
return data.data;
}
export async function updatePluginData(
pluginId: string,
entity: string,
id: string,
recordData: Record<string, unknown>,
version: number,
) {
const { data } = await client.put<{ success: boolean; data: PluginDataRecord }>(
`/plugins/${pluginId}/${entity}/${id}`,
{ data: recordData, version },
);
return data.data;
}
export async function deletePluginData(
pluginId: string,
entity: string,
id: string,
) {
await client.delete(`/plugins/${pluginId}/${entity}/${id}`);
}

121
apps/web/src/api/plugins.ts Normal file
View File

@@ -0,0 +1,121 @@
import client from './client';
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface PluginEntityInfo {
name: string;
display_name: string;
table_name: string;
}
export interface PluginPermissionInfo {
code: string;
name: string;
description: string;
}
export type PluginStatus = 'uploaded' | 'installed' | 'enabled' | 'running' | 'disabled' | 'uninstalled';
export interface PluginInfo {
id: string;
name: string;
version: string;
description?: string;
author?: string;
status: PluginStatus;
config: Record<string, unknown>;
installed_at?: string;
enabled_at?: string;
entities: PluginEntityInfo[];
permissions?: PluginPermissionInfo[];
record_version: number;
}
export async function listPlugins(page = 1, pageSize = 20, status?: string) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<PluginInfo> }>(
'/admin/plugins',
{ params: { page, page_size: pageSize, status: status || undefined } },
);
return data.data;
}
export async function getPlugin(id: string) {
const { data } = await client.get<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}`,
);
return data.data;
}
export async function uploadPlugin(wasmFile: File, manifestToml: string) {
const formData = new FormData();
formData.append('wasm', wasmFile);
formData.append('manifest', manifestToml);
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
'/admin/plugins/upload',
formData,
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000 },
);
return data.data;
}
export async function installPlugin(id: string) {
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}/install`,
);
return data.data;
}
export async function enablePlugin(id: string) {
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}/enable`,
);
return data.data;
}
export async function disablePlugin(id: string) {
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}/disable`,
);
return data.data;
}
export async function uninstallPlugin(id: string) {
const { data } = await client.post<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}/uninstall`,
);
return data.data;
}
export async function purgePlugin(id: string) {
await client.delete(`/admin/plugins/${id}`);
}
export async function getPluginHealth(id: string) {
const { data } = await client.get<{
success: boolean;
data: { plugin_id: string; status: string; details: Record<string, unknown> };
}>(`/admin/plugins/${id}/health`);
return data.data;
}
export async function updatePluginConfig(id: string, config: Record<string, unknown>, version: number) {
const { data } = await client.put<{ success: boolean; data: PluginInfo }>(
`/admin/plugins/${id}/config`,
{ config, version },
);
return data.data;
}
export async function getPluginSchema(id: string) {
const { data } = await client.get<{ success: boolean; data: Record<string, unknown> }>(
`/admin/plugins/${id}/schema`,
);
return data.data;
}

75
apps/web/src/api/roles.ts Normal file
View File

@@ -0,0 +1,75 @@
import client from './client';
import type { PaginatedResponse } from './users';
export interface RoleInfo {
id: string;
name: string;
code: string;
description?: string;
is_system: boolean;
version: number;
}
export interface PermissionInfo {
id: string;
code: string;
name: string;
resource: string;
action: string;
description?: string;
}
export interface CreateRoleRequest {
name: string;
code: string;
description?: string;
}
export interface UpdateRoleRequest {
name?: string;
description?: string;
version: number;
}
export async function listRoles(page = 1, pageSize = 20) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<RoleInfo> }>(
'/roles',
{ params: { page, page_size: pageSize } },
);
return data.data;
}
export async function getRole(id: string) {
const { data } = await client.get<{ success: boolean; data: RoleInfo }>(`/roles/${id}`);
return data.data;
}
export async function createRole(req: CreateRoleRequest) {
const { data } = await client.post<{ success: boolean; data: RoleInfo }>('/roles', req);
return data.data;
}
export async function updateRole(id: string, req: UpdateRoleRequest) {
const { data } = await client.put<{ success: boolean; data: RoleInfo }>(`/roles/${id}`, req);
return data.data;
}
export async function deleteRole(id: string) {
await client.delete(`/roles/${id}`);
}
export async function assignPermissions(roleId: string, permissionIds: string[]) {
await client.post(`/roles/${roleId}/permissions`, { permission_ids: permissionIds });
}
export async function getRolePermissions(roleId: string) {
const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>(
`/roles/${roleId}/permissions`,
);
return data.data;
}
export async function listPermissions() {
const { data } = await client.get<{ success: boolean; data: PermissionInfo[] }>('/permissions');
return data.data;
}

View File

@@ -0,0 +1,29 @@
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;
}
export async function deleteSetting(key: string) {
await client.delete(`/config/settings/${encodeURIComponent(key)}`);
}

View File

@@ -0,0 +1,22 @@
import client from './client';
export interface ThemeConfig {
primary_color?: string;
logo_url?: string;
sidebar_style?: 'light' | 'dark';
}
export async function getTheme() {
const { data } = await client.get<{ success: boolean; data: ThemeConfig }>(
'/config/themes',
);
return data.data;
}
export async function updateTheme(theme: ThemeConfig) {
const { data } = await client.put<{ success: boolean; data: ThemeConfig }>(
'/config/themes',
theme,
);
return data.data;
}

57
apps/web/src/api/users.ts Normal file
View File

@@ -0,0 +1,57 @@
import client from './client';
import type { UserInfo } from './auth';
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
export interface CreateUserRequest {
username: string;
password: string;
email?: string;
phone?: string;
display_name?: string;
}
export interface UpdateUserRequest {
email?: string;
phone?: string;
display_name?: string;
status?: string;
version: number;
}
export async function listUsers(page = 1, pageSize = 20, search = '') {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<UserInfo> }>(
'/users',
{ params: { page, page_size: pageSize, search: search || undefined } }
);
return data.data;
}
export async function getUser(id: string) {
const { data } = await client.get<{ success: boolean; data: UserInfo }>(`/users/${id}`);
return data.data;
}
export async function createUser(req: CreateUserRequest) {
const { data } = await client.post<{ success: boolean; data: UserInfo }>('/users', req);
return data.data;
}
export async function updateUser(id: string, req: UpdateUserRequest) {
const { data } = await client.put<{ success: boolean; data: UserInfo }>(`/users/${id}`, req);
return data.data;
}
export async function deleteUser(id: string) {
await client.delete(`/users/${id}`);
}
export async function assignRoles(userId: string, roleIds: string[]) {
await client.post(`/users/${userId}/roles`, { role_ids: roleIds });
}

View File

@@ -0,0 +1,89 @@
import client from './client';
import type { PaginatedResponse } from './users';
export interface NodeDef {
id: string;
type: 'StartEvent' | 'EndEvent' | 'UserTask' | 'ServiceTask' | 'ExclusiveGateway' | 'ParallelGateway';
name: string;
assignee_id?: string;
candidate_groups?: string[];
service_type?: string;
position?: { x: number; y: number };
}
export interface EdgeDef {
id: string;
source: string;
target: string;
condition?: string;
label?: string;
}
export interface ProcessDefinitionInfo {
id: string;
name: string;
key: string;
version: number;
category?: string;
description?: string;
nodes: NodeDef[];
edges: EdgeDef[];
status: string;
created_at: string;
updated_at: string;
}
export interface CreateProcessDefinitionRequest {
name: string;
key: string;
category?: string;
description?: string;
nodes: NodeDef[];
edges: EdgeDef[];
}
export interface UpdateProcessDefinitionRequest {
name?: string;
category?: string;
description?: string;
nodes?: NodeDef[];
edges?: EdgeDef[];
}
export async function listProcessDefinitions(page = 1, pageSize = 20) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<ProcessDefinitionInfo> }>(
'/workflow/definitions',
{ params: { page, page_size: pageSize } },
);
return data.data;
}
export async function getProcessDefinition(id: string) {
const { data } = await client.get<{ success: boolean; data: ProcessDefinitionInfo }>(
`/workflow/definitions/${id}`,
);
return data.data;
}
export async function createProcessDefinition(req: CreateProcessDefinitionRequest) {
const { data } = await client.post<{ success: boolean; data: ProcessDefinitionInfo }>(
'/workflow/definitions',
req,
);
return data.data;
}
export async function updateProcessDefinition(id: string, req: UpdateProcessDefinitionRequest) {
const { data } = await client.put<{ success: boolean; data: ProcessDefinitionInfo }>(
`/workflow/definitions/${id}`,
req,
);
return data.data;
}
export async function publishProcessDefinition(id: string) {
const { data } = await client.post<{ success: boolean; data: ProcessDefinitionInfo }>(
`/workflow/definitions/${id}/publish`,
);
return data.data;
}

View File

@@ -0,0 +1,72 @@
import client from './client';
import type { PaginatedResponse } from './users';
export interface TokenInfo {
id: string;
node_id: string;
status: string;
created_at: string;
}
export interface ProcessInstanceInfo {
id: string;
definition_id: string;
definition_name?: string;
business_key?: string;
status: string;
started_by: string;
started_at: string;
completed_at?: string;
created_at: string;
active_tokens: TokenInfo[];
}
export interface StartInstanceRequest {
definition_id: string;
business_key?: string;
variables?: Array<{ name: string; var_type?: string; value: unknown }>;
}
export async function startInstance(req: StartInstanceRequest) {
const { data } = await client.post<{ success: boolean; data: ProcessInstanceInfo }>(
'/workflow/instances',
req,
);
return data.data;
}
export async function listInstances(page = 1, pageSize = 20) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<ProcessInstanceInfo> }>(
'/workflow/instances',
{ params: { page, page_size: pageSize } },
);
return data.data;
}
export async function getInstance(id: string) {
const { data } = await client.get<{ success: boolean; data: ProcessInstanceInfo }>(
`/workflow/instances/${id}`,
);
return data.data;
}
export async function suspendInstance(id: string) {
const { data } = await client.post<{ success: boolean; data: null }>(
`/workflow/instances/${id}/suspend`,
);
return data.data;
}
export async function resumeInstance(id: string) {
const { data } = await client.post<{ success: boolean; data: null }>(
`/workflow/instances/${id}/resume`,
);
return data.data;
}
export async function terminateInstance(id: string) {
const { data } = await client.post<{ success: boolean; data: null }>(
`/workflow/instances/${id}/terminate`,
);
return data.data;
}

View File

@@ -0,0 +1,61 @@
import client from './client';
import type { PaginatedResponse } from './users';
export interface TaskInfo {
id: string;
instance_id: string;
token_id: string;
node_id: string;
node_name?: string;
assignee_id?: string;
candidate_groups?: unknown;
status: string;
outcome?: string;
form_data?: unknown;
due_date?: string;
completed_at?: string;
created_at: string;
definition_name?: string;
business_key?: string;
}
export interface CompleteTaskRequest {
outcome: string;
form_data?: Record<string, unknown>;
}
export interface DelegateTaskRequest {
delegate_to: string;
}
export async function listPendingTasks(page = 1, pageSize = 20) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<TaskInfo> }>(
'/workflow/tasks/pending',
{ params: { page, page_size: pageSize } },
);
return data.data;
}
export async function listCompletedTasks(page = 1, pageSize = 20) {
const { data } = await client.get<{ success: boolean; data: PaginatedResponse<TaskInfo> }>(
'/workflow/tasks/completed',
{ params: { page, page_size: pageSize } },
);
return data.data;
}
export async function completeTask(id: string, req: CompleteTaskRequest) {
const { data } = await client.post<{ success: boolean; data: TaskInfo }>(
`/workflow/tasks/${id}/complete`,
req,
);
return data.data;
}
export async function delegateTask(id: string, req: DelegateTaskRequest) {
const { data } = await client.post<{ success: boolean; data: TaskInfo }>(
`/workflow/tasks/${id}/delegate`,
req,
);
return data.data;
}

View File

@@ -0,0 +1,184 @@
import { useEffect, useRef } from 'react';
import { Badge, List, Popover, Button, Empty, Typography, theme } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useMessageStore } from '../stores/message';
const { Text } = Typography;
export default function NotificationPanel() {
const navigate = useNavigate();
// 使用独立 selector数据订阅和函数引用分离避免 effect 重复触发
const unreadCount = useMessageStore((s) => s.unreadCount);
const recentMessages = useMessageStore((s) => s.recentMessages);
const markAsRead = useMessageStore((s) => s.markAsRead);
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const initializedRef = useRef(false);
useEffect(() => {
// 防止 StrictMode 双重 mount 和路由切换导致的重复初始化
if (initializedRef.current) return;
initializedRef.current = true;
const { fetchUnreadCount, fetchRecentMessages } = useMessageStore.getState();
fetchUnreadCount();
fetchRecentMessages();
const interval = setInterval(() => {
fetchUnreadCount();
fetchRecentMessages();
}, 60000);
return () => {
clearInterval(interval);
initializedRef.current = false;
};
}, []);
const content = (
<div style={{ width: 360 }}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
padding: '4px 0',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}></span>
{unreadCount > 0 && (
<Button
type="text"
size="small"
style={{ fontSize: 12, color: '#4F46E5' }}
onClick={() => navigate('/messages')}
>
</Button>
)}
</div>
{recentMessages.length === 0 ? (
<Empty
description="暂无消息"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: '24px 0' }}
/>
) : (
<List
dataSource={recentMessages.slice(0, 5)}
renderItem={(item) => (
<List.Item
style={{
padding: '10px 12px',
margin: '2px 0',
borderRadius: 8,
cursor: 'pointer',
transition: 'background 0.15s ease',
border: 'none',
background: !item.is_read ? (isDark ? '#1E293B' : '#F5F3FF') : 'transparent',
}}
onClick={() => {
if (!item.is_read) {
markAsRead(item.id);
}
}}
onMouseEnter={(e) => {
if (item.is_read) {
e.currentTarget.style.background = isDark ? '#1E293B' : '#F8FAFC';
}
}}
onMouseLeave={(e) => {
if (item.is_read) {
e.currentTarget.style.background = 'transparent';
}
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text
strong={!item.is_read}
ellipsis
style={{ maxWidth: 260, fontSize: 13 }}
>
{item.title}
</Text>
{!item.is_read && (
<span style={{
display: 'inline-block',
width: 6,
height: 6,
borderRadius: '50%',
background: '#4F46E5',
flexShrink: 0,
}} />
)}
</div>
<Text
type="secondary"
ellipsis
style={{ maxWidth: 300, fontSize: 12, display: 'block', marginTop: 2 }}
>
{item.body}
</Text>
</div>
</List.Item>
)}
/>
)}
{recentMessages.length > 0 && (
<div style={{
textAlign: 'center',
paddingTop: 8,
marginTop: 4,
borderTop: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
}}>
<Button
type="text"
onClick={() => navigate('/messages')}
style={{ fontSize: 13, color: '#4F46E5', fontWeight: 500 }}
>
</Button>
</div>
)}
</div>
);
return (
<Popover
content={content}
trigger="click"
placement="bottomRight"
overlayStyle={{ padding: 0 }}
>
<div
style={{
width: 36,
height: 36,
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
transition: 'all 0.15s ease',
position: 'relative',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = isDark ? '#1E293B' : '#F1F5F9';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
}}
>
<Badge count={unreadCount} size="small" offset={[4, -4]}>
<BellOutlined style={{
fontSize: 16,
color: isDark ? '#94A3B8' : '#64748B',
}} />
</Badge>
</div>
</Popover>
);
}

1139
apps/web/src/index.css Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
import { useCallback, memo, useEffect } from 'react';
import { Layout, Avatar, Space, Dropdown, Tooltip, theme } from 'antd';
import {
HomeOutlined,
UserOutlined,
SafetyOutlined,
ApartmentOutlined,
SettingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
PartitionOutlined,
LogoutOutlined,
MessageOutlined,
SearchOutlined,
BulbOutlined,
BulbFilled,
AppstoreOutlined,
} from '@ant-design/icons';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAppStore } from '../stores/app';
import { useAuthStore } from '../stores/auth';
import { usePluginStore } from '../stores/plugin';
import NotificationPanel from '../components/NotificationPanel';
const { Header, Sider, Content, Footer } = Layout;
interface MenuItem {
key: string;
icon: React.ReactNode;
label: string;
}
const mainMenuItems: MenuItem[] = [
{ key: '/', icon: <HomeOutlined />, label: '工作台' },
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
{ key: '/roles', icon: <SafetyOutlined />, label: '权限管理' },
{ key: '/organizations', icon: <ApartmentOutlined />, label: '组织架构' },
];
const bizMenuItems: MenuItem[] = [
{ key: '/workflow', icon: <PartitionOutlined />, label: '工作流' },
{ key: '/messages', icon: <MessageOutlined />, label: '消息中心' },
];
const sysMenuItems: MenuItem[] = [
{ key: '/settings', icon: <SettingOutlined />, label: '系统设置' },
{ key: '/plugins/admin', icon: <AppstoreOutlined />, label: '插件管理' },
];
const routeTitleMap: Record<string, string> = {
'/': '工作台',
'/users': '用户管理',
'/roles': '权限管理',
'/organizations': '组织架构',
'/workflow': '工作流',
'/messages': '消息中心',
'/settings': '系统设置',
'/plugins/admin': '插件管理',
};
// 侧边栏菜单项 - 提取为独立组件避免重复渲染
const SidebarMenuItem = memo(function SidebarMenuItem({
item,
isActive,
collapsed,
onClick,
}: {
item: MenuItem;
isActive: boolean;
collapsed: boolean;
onClick: () => void;
}) {
return (
<Tooltip title={collapsed ? item.label : ''} placement="right">
<div
onClick={onClick}
className={`erp-sidebar-item ${isActive ? 'erp-sidebar-item-active' : ''}`}
>
<span className="erp-sidebar-item-icon">{item.icon}</span>
{!collapsed && <span className="erp-sidebar-item-label">{item.label}</span>}
</div>
</Tooltip>
);
});
export default function MainLayout({ children }: { children: React.ReactNode }) {
const { sidebarCollapsed, toggleSidebar, theme: themeMode, setTheme } = useAppStore();
const { user, logout } = useAuthStore();
const { pluginMenuItems, fetchPlugins } = usePluginStore();
theme.useToken();
const navigate = useNavigate();
const location = useLocation();
const currentPath = location.pathname || '/';
// 加载插件菜单
useEffect(() => {
fetchPlugins(1, 'running');
}, [fetchPlugins]);
const handleLogout = useCallback(async () => {
await logout();
navigate('/login');
}, [logout, navigate]);
const userMenuItems = [
{
key: 'profile',
icon: <UserOutlined />,
label: user?.display_name || user?.username || '用户',
disabled: true,
},
{ type: 'divider' as const },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
danger: true,
onClick: handleLogout,
},
];
const sidebarWidth = sidebarCollapsed ? 72 : 240;
const isDark = themeMode === 'dark';
return (
<Layout style={{ minHeight: '100vh' }}>
{/* 现代深色侧边栏 */}
<Sider
trigger={null}
collapsible
collapsed={sidebarCollapsed}
width={240}
collapsedWidth={72}
className={isDark ? 'erp-sider-dark' : 'erp-sider-dark erp-sider-default'}
>
{/* Logo 区域 */}
<div className="erp-sidebar-logo" onClick={() => navigate('/')}>
<div className="erp-sidebar-logo-icon">E</div>
{!sidebarCollapsed && (
<span className="erp-sidebar-logo-text">ERP Platform</span>
)}
</div>
{/* 菜单组:基础模块 */}
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{mainMenuItems.map((item) => (
<SidebarMenuItem
key={item.key}
item={item}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
))}
</div>
{/* 菜单组:业务模块 */}
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{bizMenuItems.map((item) => (
<SidebarMenuItem
key={item.key}
item={item}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
))}
</div>
{/* 菜单组:插件 */}
{pluginMenuItems.length > 0 && (
<>
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{pluginMenuItems.map((item) => (
<SidebarMenuItem
key={item.key}
item={{
key: item.key,
icon: <AppstoreOutlined />,
label: item.label,
}}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
))}
</div>
</>
)}
{/* 菜单组:系统 */}
{!sidebarCollapsed && <div className="erp-sidebar-group"></div>}
<div className="erp-sidebar-menu">
{sysMenuItems.map((item) => (
<SidebarMenuItem
key={item.key}
item={item}
isActive={currentPath === item.key}
collapsed={sidebarCollapsed}
onClick={() => navigate(item.key)}
/>
))}
</div>
</Sider>
{/* 右侧主区域 */}
<Layout
className={`erp-main-layout ${isDark ? 'erp-main-layout-dark' : 'erp-main-layout-light'}`}
style={{ marginLeft: sidebarWidth }}
>
{/* 顶部导航栏 */}
<Header className={`erp-header ${isDark ? 'erp-header-dark' : 'erp-header-light'}`}>
{/* 左侧:折叠按钮 + 标题 */}
<Space size="middle" style={{ alignItems: 'center' }}>
<div className="erp-header-btn" onClick={toggleSidebar}>
{sidebarCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
</div>
<span className={`erp-header-title ${isDark ? 'erp-text-dark' : 'erp-text-light'}`}>
{routeTitleMap[currentPath] ||
pluginMenuItems.find((p) => p.key === currentPath)?.label ||
'页面'}
</span>
</Space>
{/* 右侧:搜索 + 通知 + 主题切换 + 用户 */}
<Space size={4} style={{ alignItems: 'center' }}>
<Tooltip title="搜索">
<div className="erp-header-btn">
<SearchOutlined style={{ fontSize: 16 }} />
</div>
</Tooltip>
<Tooltip title={isDark ? '切换亮色模式' : '切换暗色模式'}>
<div className="erp-header-btn" onClick={() => setTheme(isDark ? 'light' : 'dark')}>
{isDark ? <BulbFilled style={{ fontSize: 16 }} /> : <BulbOutlined style={{ fontSize: 16 }} />}
</div>
</Tooltip>
<NotificationPanel />
<div className={`erp-header-divider ${isDark ? 'erp-header-divider-dark' : 'erp-header-divider-light'}`} />
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight" trigger={['click']}>
<div className="erp-header-user">
<Avatar
size={30}
className="erp-user-avatar"
>
{(user?.display_name?.[0] || user?.username?.[0] || 'U').toUpperCase()}
</Avatar>
{!sidebarCollapsed && (
<span className={`erp-user-name ${isDark ? 'erp-text-dark-secondary' : 'erp-text-light-secondary'}`}>
{user?.display_name || user?.username || 'User'}
</span>
)}
</div>
</Dropdown>
</Space>
</Header>
{/* 内容区域 */}
<Content style={{ padding: 20, minHeight: 'calc(100vh - 56px - 48px)' }}>
{children}
</Content>
{/* 底部 */}
<Footer className={`erp-footer ${isDark ? 'erp-footer-dark' : 'erp-footer-light'}`}>
ERP Platform v0.1.0
</Footer>
</Layout>
</Layout>
);
}

10
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

418
apps/web/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,418 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { Row, Col, Spin, theme } from 'antd';
import {
UserOutlined,
SafetyCertificateOutlined,
FileTextOutlined,
BellOutlined,
ThunderboltOutlined,
SettingOutlined,
PartitionOutlined,
ClockCircleOutlined,
ApartmentOutlined,
CheckCircleOutlined,
TeamOutlined,
FileProtectOutlined,
RiseOutlined,
FallOutlined,
RightOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import client from '../api/client';
import { useMessageStore } from '../stores/message';
interface DashboardStats {
userCount: number;
roleCount: number;
processInstanceCount: number;
unreadMessages: number;
}
interface TrendData {
value: string;
direction: 'up' | 'down' | 'neutral';
label: string;
}
interface StatCardConfig {
key: string;
title: string;
value: number;
icon: React.ReactNode;
gradient: string;
iconBg: string;
delay: string;
trend: TrendData;
sparkline: number[];
onClick?: () => void;
}
interface TaskItem {
id: string;
title: string;
priority: 'high' | 'medium' | 'low';
assignee: string;
dueText: string;
color: string;
icon: React.ReactNode;
path: string;
}
interface ActivityItem {
id: string;
text: string;
time: string;
icon: React.ReactNode;
}
function useCountUp(end: number, duration = 800) {
const [count, setCount] = useState(0);
const prevEnd = useRef(end);
useEffect(() => {
if (end === prevEnd.current && count > 0) return;
prevEnd.current = end;
if (end === 0) { setCount(0); return; }
const startTime = performance.now();
const startVal = 0;
function tick(now: number) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
setCount(Math.round(startVal + (end - startVal) * eased));
if (progress < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}, [end, duration]);
return count;
}
function StatValue({ value, loading }: { value: number; loading: boolean }) {
const animatedValue = useCountUp(value);
if (loading) return <Spin size="small" />;
return <span className="erp-count-up">{animatedValue.toLocaleString()}</span>;
}
export default function Home() {
const [stats, setStats] = useState<DashboardStats>({
userCount: 0,
roleCount: 0,
processInstanceCount: 0,
unreadMessages: 0,
});
const [loading, setLoading] = useState(true);
const unreadCount = useMessageStore((s) => s.unreadCount);
const fetchUnreadCount = useMessageStore((s) => s.fetchUnreadCount);
const { token } = theme.useToken();
const navigate = useNavigate();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
useEffect(() => {
let cancelled = false;
async function loadStats() {
setLoading(true);
try {
const [usersRes, rolesRes, instancesRes] = await Promise.allSettled([
client.get('/users', { params: { page: 1, page_size: 1 } }),
client.get('/roles', { params: { page: 1, page_size: 1 } }),
client.get('/workflow/instances', { params: { page: 1, page_size: 1 } }),
]);
if (cancelled) return;
const extractTotal = (res: PromiseSettledResult<{ data: { data?: { total?: number } } }>) =>
res.status === 'fulfilled' ? (res.value.data?.data?.total ?? 0) : 0;
setStats({
userCount: extractTotal(usersRes),
roleCount: extractTotal(rolesRes),
processInstanceCount: extractTotal(instancesRes),
unreadMessages: unreadCount,
});
} catch {
// 静默处理
} finally {
if (!cancelled) setLoading(false);
}
}
fetchUnreadCount();
loadStats();
return () => { cancelled = true; };
}, [fetchUnreadCount, unreadCount]);
const handleNavigate = useCallback((path: string) => {
navigate(path);
}, [navigate]);
const statCards: StatCardConfig[] = [
{
key: 'users',
title: '用户总数',
value: stats.userCount,
icon: <UserOutlined />,
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)',
iconBg: 'rgba(79, 70, 229, 0.12)',
delay: 'erp-fade-in erp-fade-in-delay-1',
trend: { value: '+2', direction: 'up', label: '较上周' },
sparkline: [30, 45, 35, 50, 40, 55, 60, 50, 65, 70],
onClick: () => handleNavigate('/users'),
},
{
key: 'roles',
title: '角色数量',
value: stats.roleCount,
icon: <SafetyCertificateOutlined />,
gradient: 'linear-gradient(135deg, #059669, #10B981)',
iconBg: 'rgba(5, 150, 105, 0.12)',
delay: 'erp-fade-in erp-fade-in-delay-2',
trend: { value: '+1', direction: 'up', label: '较上月' },
sparkline: [20, 25, 30, 28, 35, 40, 38, 42, 45, 50],
onClick: () => handleNavigate('/roles'),
},
{
key: 'processes',
title: '流程实例',
value: stats.processInstanceCount,
icon: <FileTextOutlined />,
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)',
iconBg: 'rgba(217, 119, 6, 0.12)',
delay: 'erp-fade-in erp-fade-in-delay-3',
trend: { value: '0', direction: 'neutral', label: '较昨日' },
sparkline: [10, 15, 12, 20, 18, 25, 22, 28, 24, 20],
onClick: () => handleNavigate('/workflow'),
},
{
key: 'messages',
title: '未读消息',
value: stats.unreadMessages,
icon: <BellOutlined />,
gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)',
iconBg: 'rgba(225, 29, 72, 0.12)',
delay: 'erp-fade-in erp-fade-in-delay-4',
trend: { value: '0', direction: 'neutral', label: '全部已读' },
sparkline: [5, 8, 3, 10, 6, 12, 8, 4, 7, 5],
onClick: () => handleNavigate('/messages'),
},
];
const quickActions = [
{ icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#4F46E5' },
{ icon: <SafetyCertificateOutlined />, label: '权限管理', path: '/roles', color: '#059669' },
{ icon: <ApartmentOutlined />, label: '组织架构', path: '/organizations', color: '#D97706' },
{ icon: <PartitionOutlined />, label: '工作流', path: '/workflow', color: '#7C3AED' },
{ icon: <BellOutlined />, label: '消息中心', path: '/messages', color: '#E11D48' },
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#64748B' },
];
const pendingTasks: TaskItem[] = [
{ id: '1', title: '审核新用户注册申请', priority: 'high', assignee: '系统', dueText: '待处理', color: '#DC2626', icon: <UserOutlined />, path: '/users' },
{ id: '2', title: '配置工作流审批节点', priority: 'medium', assignee: '管理员', dueText: '进行中', color: '#D97706', icon: <PartitionOutlined />, path: '/workflow' },
{ id: '3', title: '更新角色权限策略', priority: 'low', assignee: '管理员', dueText: '计划中', color: '#059669', icon: <SafetyCertificateOutlined />, path: '/roles' },
];
const recentActivities: ActivityItem[] = [
{ id: '1', text: '系统管理员 创建了 <strong>管理员角色</strong>', time: '刚刚', icon: <TeamOutlined /> },
{ id: '2', text: '系统管理员 配置了 <strong>工作流模板</strong>', time: '5 分钟前', icon: <FileProtectOutlined /> },
{ id: '3', text: '系统管理员 更新了 <strong>组织架构</strong>', time: '10 分钟前', icon: <ApartmentOutlined /> },
{ id: '4', text: '系统管理员 设置了 <strong>消息通知偏好</strong>', time: '30 分钟前', icon: <BellOutlined /> },
];
const priorityLabel: Record<string, string> = { high: '紧急', medium: '一般', low: '低' };
return (
<div>
{/* 欢迎语 */}
<div className="erp-fade-in" style={{ marginBottom: 24 }}>
<h2 style={{
fontSize: 24,
fontWeight: 700,
color: isDark ? '#F1F5F9' : '#0F172A',
margin: '0 0 4px',
letterSpacing: '-0.5px',
}}>
</h2>
<p style={{ fontSize: 14, color: isDark ? '#94A3B8' : '#475569', margin: 0 }}>
</p>
</div>
{/* 统计卡片行 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{statCards.map((card) => {
const maxSpark = Math.max(...card.sparkline, 1);
return (
<Col xs={24} sm={12} lg={6} key={card.key}>
<div
className={`erp-stat-card ${card.delay}`}
style={{ '--card-gradient': card.gradient, '--card-icon-bg': card.iconBg } as React.CSSProperties}
onClick={card.onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') card.onClick?.(); }}
>
<div className="erp-stat-card-bar" />
<div className="erp-stat-card-body">
<div className="erp-stat-card-info">
<div className="erp-stat-card-title">{card.title}</div>
<div className="erp-stat-card-value">
<StatValue value={card.value} loading={loading} />
</div>
<div className={`erp-stat-card-trend erp-stat-card-trend-${card.trend.direction}`}>
{card.trend.direction === 'up' && <RiseOutlined />}
{card.trend.direction === 'down' && <FallOutlined />}
<span>{card.trend.value}</span>
<span className="erp-stat-card-trend-label">{card.trend.label}</span>
</div>
</div>
<div className="erp-stat-card-icon">{card.icon}</div>
</div>
<div className="erp-stat-card-sparkline">
{card.sparkline.map((v, i) => (
<div
key={i}
className="erp-stat-card-sparkline-bar"
style={{
height: `${(v / maxSpark) * 100}%`,
background: card.gradient,
}}
/>
))}
</div>
</div>
</Col>
);
})}
</Row>
{/* 待办任务 + 最近活动 */}
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{/* 待办任务 */}
<Col xs={24} lg={14}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-2">
<div className="erp-section-header">
<CheckCircleOutlined className="erp-section-icon" style={{ color: '#E11D48' }} />
<span className="erp-section-title"></span>
<span style={{
marginLeft: 'auto',
fontSize: 12,
color: isDark ? '#94A3B8' : '#64748B',
}}>
{pendingTasks.length}
</span>
</div>
<div className="erp-task-list">
{pendingTasks.map((task) => (
<div
key={task.id}
className="erp-task-item"
style={{ '--task-color': task.color } as React.CSSProperties}
onClick={() => handleNavigate(task.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(task.path); }}
>
<div className="erp-task-item-icon">{task.icon}</div>
<div className="erp-task-item-content">
<div className="erp-task-item-title">{task.title}</div>
<div className="erp-task-item-meta">
<span>{task.assignee}</span>
<span>{task.dueText}</span>
</div>
</div>
<span className={`erp-task-priority erp-task-priority-${task.priority}`}>
{priorityLabel[task.priority]}
</span>
<RightOutlined style={{ color: isDark ? '#475569' : '#CBD5E1', fontSize: 12 }} />
</div>
))}
</div>
</div>
</Col>
{/* 最近活动 */}
<Col xs={24} lg={10}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3" style={{ height: '100%' }}>
<div className="erp-section-header">
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#6366F1' }} />
<span className="erp-section-title"></span>
</div>
<div className="erp-activity-list">
{recentActivities.map((activity) => (
<div key={activity.id} className="erp-activity-item">
<div className="erp-activity-dot">{activity.icon}</div>
<div className="erp-activity-content">
<div className="erp-activity-text" dangerouslySetInnerHTML={{ __html: activity.text }} />
<div className="erp-activity-time">{activity.time}</div>
</div>
</div>
))}
</div>
</div>
</Col>
</Row>
{/* 快捷入口 + 系统信息 */}
<Row gutter={[16, 16]}>
<Col xs={24} lg={16}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3">
<div className="erp-section-header">
<ThunderboltOutlined className="erp-section-icon" />
<span className="erp-section-title"></span>
</div>
<Row gutter={[12, 12]}>
{quickActions.map((action) => (
<Col xs={12} sm={8} md={8} key={action.path}>
<div
className="erp-quick-action"
style={{ '--action-color': action.color } as React.CSSProperties}
onClick={() => handleNavigate(action.path)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter') handleNavigate(action.path); }}
>
<div className="erp-quick-action-icon">{action.icon}</div>
<span className="erp-quick-action-label">{action.label}</span>
</div>
</Col>
))}
</Row>
</div>
</Col>
<Col xs={24} lg={8}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-4" style={{ height: '100%' }}>
<div className="erp-section-header">
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#6366F1' }} />
<span className="erp-section-title"></span>
</div>
<div className="erp-system-info-list">
{[
{ label: '系统版本', value: 'v0.1.0' },
{ label: '后端框架', value: 'Axum 0.8 + Tokio' },
{ label: '数据库', value: 'PostgreSQL 16' },
{ label: '缓存', value: 'Redis 7' },
{ label: '前端框架', value: 'React 19 + Ant Design 6' },
{ label: '模块数量', value: '5 个业务模块' },
].map((item) => (
<div key={item.label} className="erp-system-info-item">
<span className="erp-system-info-label">{item.label}</span>
<span className="erp-system-info-value">{item.value}</span>
</div>
))}
</div>
</div>
</Col>
</Row>
</div>
);
}

View File

@@ -0,0 +1,208 @@
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, message, Divider } from 'antd';
import { UserOutlined, LockOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import { useAuthStore } from '../stores/auth';
export default function Login() {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const loading = useAuthStore((s) => s.loading);
const [messageApi, contextHolder] = message.useMessage();
const onFinish = async (values: { username: string; password: string }) => {
try {
await login(values.username, values.password);
messageApi.success('登录成功');
navigate('/');
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
'登录失败,请检查用户名和密码';
messageApi.error(errorMsg);
}
};
return (
<div style={{ display: 'flex', minHeight: '100vh' }}>
{contextHolder}
{/* 左侧品牌展示区 */}
<div
style={{
flex: 1,
background: 'linear-gradient(135deg, #312E81 0%, #4F46E5 50%, #6366F1 100%)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
padding: '60px',
position: 'relative',
overflow: 'hidden',
}}
>
{/* 装饰性背景元素 */}
<div
style={{
position: 'absolute',
top: '-20%',
right: '-10%',
width: '500px',
height: '500px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.05)',
}}
/>
<div
style={{
position: 'absolute',
bottom: '-15%',
left: '-8%',
width: '400px',
height: '400px',
borderRadius: '50%',
background: 'rgba(255, 255, 255, 0.03)',
}}
/>
{/* 品牌内容 */}
<div style={{ position: 'relative', zIndex: 1, textAlign: 'center', maxWidth: '480px' }}>
<div
style={{
width: 64,
height: 64,
borderRadius: 16,
background: 'rgba(255, 255, 255, 0.15)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 32px',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255, 255, 255, 0.2)',
}}
>
<SafetyCertificateOutlined style={{ fontSize: 32, color: '#fff' }} />
</div>
<h1
style={{
color: '#fff',
fontSize: 36,
fontWeight: 800,
margin: '0 0 16px',
letterSpacing: '-1px',
lineHeight: 1.2,
}}
>
ERP Platform
</h1>
<p
style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 16,
lineHeight: 1.6,
margin: 0,
}}
>
</p>
<p
style={{
color: 'rgba(255, 255, 255, 0.5)',
fontSize: 14,
lineHeight: 1.6,
marginTop: 8,
}}
>
· · ·
</p>
{/* 底部特性点 */}
<div style={{ marginTop: 48, display: 'flex', gap: 32, justifyContent: 'center' }}>
{[
{ label: '多租户架构', value: 'SaaS' },
{ label: '模块化设计', value: '可插拔' },
{ label: '事件驱动', value: '可扩展' },
].map((item) => (
<div key={item.label} style={{ textAlign: 'center' }}>
<div style={{ color: 'rgba(255, 255, 255, 0.9)', fontSize: 18, fontWeight: 700 }}>
{item.value}
</div>
<div style={{ color: 'rgba(255, 255, 255, 0.5)', fontSize: 12, marginTop: 4 }}>
{item.label}
</div>
</div>
))}
</div>
</div>
</div>
{/* 右侧登录表单区 */}
<main
style={{
width: 480,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '60px',
background: '#fff',
}}
>
<div style={{ maxWidth: 360, width: '100%', margin: '0 auto' }}>
<h2 style={{ marginBottom: 4, fontWeight: 700, fontSize: 24 }}>
</h2>
<p style={{ fontSize: 14, color: '#64748B' }}>
</p>
<Divider style={{ margin: '24px 0' }} />
<Form name="login" onFinish={onFinish} autoComplete="off" size="large" layout="vertical">
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined style={{ color: '#94A3B8' }} />}
placeholder="用户名"
style={{ height: 44, borderRadius: 10 }}
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#94A3B8' }} />}
placeholder="密码"
style={{ height: 44, borderRadius: 10 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={{
height: 44,
borderRadius: 10,
fontSize: 15,
fontWeight: 600,
}}
>
</Button>
</Form.Item>
</Form>
<div style={{ marginTop: 32, textAlign: 'center' }}>
<p style={{ fontSize: 12, color: '#64748B', margin: 0 }}>
ERP Platform v0.1.0 · Powered by Rust + React
</p>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { useState } from 'react';
import { Tabs } from 'antd';
import { BellOutlined, MailOutlined, FileTextOutlined, SettingOutlined } from '@ant-design/icons';
import NotificationList from './messages/NotificationList';
import MessageTemplates from './messages/MessageTemplates';
import NotificationPreferences from './messages/NotificationPreferences';
import type { MessageQuery } from '../api/messages';
const UNREAD_FILTER: MessageQuery = { is_read: false };
export default function Messages() {
const [activeKey, setActiveKey] = useState('all');
return (
<div>
<div className="erp-page-header" style={{ borderBottom: 'none', marginBottom: 0, paddingBottom: 8 }}>
<div>
<h4></h4>
<div className="erp-page-subtitle"></div>
</div>
</div>
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
style={{ marginTop: 8 }}
items={[
{
key: 'all',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<MailOutlined style={{ fontSize: 14 }} />
</span>
),
children: <NotificationList />,
},
{
key: 'unread',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<BellOutlined style={{ fontSize: 14 }} />
</span>
),
children: <NotificationList queryFilter={UNREAD_FILTER} />,
},
{
key: 'templates',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FileTextOutlined style={{ fontSize: 14 }} />
</span>
),
children: <MessageTemplates />,
},
{
key: 'preferences',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<SettingOutlined style={{ fontSize: 14 }} />
</span>
),
children: <NotificationPreferences />,
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,623 @@
import { useEffect, useState, useCallback } from 'react';
import {
Tree,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
Table,
Popconfirm,
message,
Empty,
Tag,
theme,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
ApartmentOutlined,
} from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree';
import {
listOrgTree,
createOrg,
updateOrg,
deleteOrg,
listDeptTree,
createDept,
deleteDept,
listPositions,
createPosition,
deletePosition,
type OrganizationInfo,
type DepartmentInfo,
type PositionInfo,
} from '../api/orgs';
export default function Organizations() {
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const cardStyle = {
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
};
// --- Org tree state ---
const [orgTree, setOrgTree] = useState<OrganizationInfo[]>([]);
const [selectedOrg, setSelectedOrg] = useState<OrganizationInfo | null>(null);
const [, setLoading] = useState(false);
// --- Department tree state ---
const [deptTree, setDeptTree] = useState<DepartmentInfo[]>([]);
const [selectedDept, setSelectedDept] = useState<DepartmentInfo | null>(null);
// --- Position list state ---
const [positions, setPositions] = useState<PositionInfo[]>([]);
// --- Modal state ---
const [orgModalOpen, setOrgModalOpen] = useState(false);
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [positionModalOpen, setPositionModalOpen] = useState(false);
const [editOrg, setEditOrg] = useState<OrganizationInfo | null>(null);
const [orgForm] = Form.useForm();
const [deptForm] = Form.useForm();
const [positionForm] = Form.useForm();
// --- Fetch org tree ---
const fetchOrgTree = useCallback(async () => {
setLoading(true);
try {
const tree = await listOrgTree();
setOrgTree(tree);
if (selectedOrg) {
const stillExists = findOrgInTree(tree, selectedOrg.id);
if (!stillExists) {
setSelectedOrg(null);
setDeptTree([]);
setPositions([]);
}
}
} catch {
message.error('加载组织树失败');
}
setLoading(false);
}, [selectedOrg]);
useEffect(() => {
fetchOrgTree();
}, [fetchOrgTree]);
// --- Fetch dept tree when org selected ---
const fetchDeptTree = useCallback(async () => {
if (!selectedOrg) return;
try {
const tree = await listDeptTree(selectedOrg.id);
setDeptTree(tree);
if (selectedDept) {
const stillExists = findDeptInTree(tree, selectedDept.id);
if (!stillExists) {
setSelectedDept(null);
setPositions([]);
}
}
} catch {
message.error('加载部门树失败');
}
}, [selectedOrg, selectedDept]);
useEffect(() => {
fetchDeptTree();
}, [fetchDeptTree]);
// --- Fetch positions when dept selected ---
const fetchPositions = useCallback(async () => {
if (!selectedDept) return;
try {
const list = await listPositions(selectedDept.id);
setPositions(list);
} catch {
message.error('加载岗位列表失败');
}
}, [selectedDept]);
useEffect(() => {
fetchPositions();
}, [fetchPositions]);
// --- Org handlers ---
const handleCreateOrg = async (values: {
name: string;
code?: string;
sort_order?: number;
}) => {
try {
if (editOrg) {
await updateOrg(editOrg.id, {
name: values.name,
code: values.code,
sort_order: values.sort_order,
version: editOrg.version,
});
message.success('组织更新成功');
} else {
await createOrg({
name: values.name,
code: values.code,
parent_id: selectedOrg?.id,
sort_order: values.sort_order,
});
message.success('组织创建成功');
}
setOrgModalOpen(false);
setEditOrg(null);
orgForm.resetFields();
fetchOrgTree();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDeleteOrg = async (id: string) => {
try {
await deleteOrg(id);
message.success('组织已删除');
setSelectedOrg(null);
setDeptTree([]);
setPositions([]);
fetchOrgTree();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '删除失败';
message.error(errorMsg);
}
};
// --- Dept handlers ---
const handleCreateDept = async (values: {
name: string;
code?: string;
sort_order?: number;
}) => {
if (!selectedOrg) return;
try {
await createDept(selectedOrg.id, {
name: values.name,
code: values.code,
parent_id: selectedDept?.id,
sort_order: values.sort_order,
});
message.success('部门创建成功');
setDeptModalOpen(false);
deptForm.resetFields();
fetchDeptTree();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDeleteDept = async (id: string) => {
try {
await deleteDept(id);
message.success('部门已删除');
setSelectedDept(null);
setPositions([]);
fetchDeptTree();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '删除失败';
message.error(errorMsg);
}
};
// --- Position handlers ---
const handleCreatePosition = async (values: {
name: string;
code?: string;
level?: number;
sort_order?: number;
}) => {
if (!selectedDept) return;
try {
await createPosition(selectedDept.id, {
name: values.name,
code: values.code,
level: values.level,
sort_order: values.sort_order,
});
message.success('岗位创建成功');
setPositionModalOpen(false);
positionForm.resetFields();
fetchPositions();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDeletePosition = async (id: string) => {
try {
await deletePosition(id);
message.success('岗位已删除');
fetchPositions();
} catch {
message.error('删除失败');
}
};
// --- Tree node converters ---
const convertOrgTree = (items: OrganizationInfo[]): DataNode[] =>
items.map((item) => ({
key: item.id,
title: (
<span>
{item.name}{' '}
{item.code && <Tag style={{
marginLeft: 4,
background: isDark ? '#1E293B' : '#EEF2FF',
border: 'none',
color: '#4F46E5',
fontSize: 11,
}}>{item.code}</Tag>}
</span>
),
children: convertOrgTree(item.children),
}));
const convertDeptTree = (items: DepartmentInfo[]): DataNode[] =>
items.map((item) => ({
key: item.id,
title: (
<span>
{item.name}{' '}
{item.code && <Tag style={{
marginLeft: 4,
background: isDark ? '#1E293B' : '#ECFDF5',
border: 'none',
color: '#059669',
fontSize: 11,
}}>{item.code}</Tag>}
</span>
),
children: convertDeptTree(item.children),
}));
const onSelectOrg = (selectedKeys: React.Key[]) => {
if (selectedKeys.length === 0) {
setSelectedOrg(null);
setDeptTree([]);
setSelectedDept(null);
setPositions([]);
return;
}
const org = findOrgInTree(orgTree, selectedKeys[0] as string);
setSelectedOrg(org);
setSelectedDept(null);
setPositions([]);
};
const onSelectDept = (selectedKeys: React.Key[]) => {
if (selectedKeys.length === 0) {
setSelectedDept(null);
setPositions([]);
return;
}
const dept = findDeptInTree(deptTree, selectedKeys[0] as string);
setSelectedDept(dept);
};
const positionColumns = [
{ title: '岗位名称', dataIndex: 'name', key: 'name' },
{ title: '编码', dataIndex: 'code', key: 'code', render: (v?: string) => v || '-' },
{ title: '级别', dataIndex: 'level', key: 'level' },
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order' },
{
title: '操作',
key: 'actions',
render: (_: unknown, record: PositionInfo) => (
<Popconfirm
title="确定删除此岗位?"
onConfirm={() => handleDeletePosition(record.id)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
),
},
];
return (
<div>
{/* 页面标题 */}
<div className="erp-page-header">
<div>
<h4>
<ApartmentOutlined style={{ marginRight: 8, color: '#4F46E5' }} />
</h4>
<div className="erp-page-subtitle"></div>
</div>
</div>
{/* 三栏布局 */}
<div style={{ display: 'flex', gap: 16, minHeight: 500 }}>
{/* 左栏:组织树 */}
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}></span>
<Space size={4}>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
onClick={() => {
setEditOrg(null);
orgForm.resetFields();
setOrgModalOpen(true);
}}
/>
{selectedOrg && (
<>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => {
setEditOrg(selectedOrg);
orgForm.setFieldsValue({
name: selectedOrg.name,
code: selectedOrg.code,
sort_order: selectedOrg.sort_order,
});
setOrgModalOpen(true);
}}
/>
<Popconfirm
title="确定删除此组织?"
onConfirm={() => handleDeleteOrg(selectedOrg.id)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</>
)}
</Space>
</div>
<div style={{ padding: 12 }}>
{orgTree.length > 0 ? (
<Tree
showLine
defaultExpandAll
treeData={convertOrgTree(orgTree)}
onSelect={onSelectOrg}
selectedKeys={selectedOrg ? [selectedOrg.id] : []}
/>
) : (
<Empty description="暂无组织" />
)}
</div>
</div>
{/* 中栏:部门树 */}
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>
{selectedOrg ? `${selectedOrg.name} · 部门` : '部门'}
</span>
{selectedOrg && (
<Space size={4}>
<Button
size="small"
type="text"
icon={<PlusOutlined />}
onClick={() => {
deptForm.resetFields();
setDeptModalOpen(true);
}}
/>
{selectedDept && (
<Popconfirm
title="确定删除此部门?"
onConfirm={() => handleDeleteDept(selectedDept.id)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
)}
</div>
<div style={{ padding: 12 }}>
{selectedOrg ? (
deptTree.length > 0 ? (
<Tree
showLine
defaultExpandAll
treeData={convertDeptTree(deptTree)}
onSelect={onSelectDept}
selectedKeys={selectedDept ? [selectedDept.id] : []}
/>
) : (
<Empty description="暂无部门" />
)
) : (
<Empty description="请先选择组织" />
)}
</div>
</div>
{/* 右栏:岗位表 */}
<div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}>
<div style={{
padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<span style={{ fontWeight: 600, fontSize: 14 }}>
{selectedDept ? `${selectedDept.name} · 岗位` : '岗位'}
</span>
{selectedDept && (
<Button
size="small"
type="text"
icon={<PlusOutlined />}
onClick={() => {
positionForm.resetFields();
setPositionModalOpen(true);
}}
>
</Button>
)}
</div>
<div style={{ padding: '0 4px' }}>
{selectedDept ? (
<Table
columns={positionColumns}
dataSource={positions}
rowKey="id"
size="small"
pagination={false}
/>
) : (
<div style={{ padding: 24 }}>
<Empty description="请先选择部门" />
</div>
)}
</div>
</div>
</div>
{/* Org Modal */}
<Modal
title={editOrg ? '编辑组织' : selectedOrg ? `${selectedOrg.name} 下新建子组织` : '新建根组织'}
open={orgModalOpen}
onCancel={() => {
setOrgModalOpen(false);
setEditOrg(null);
}}
onOk={() => orgForm.submit()}
>
<Form form={orgForm} onFinish={handleCreateOrg} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入组织名称' }]}
>
<Input />
</Form.Item>
<Form.Item name="code" label="编码">
<Input />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
{/* Dept Modal */}
<Modal
title={
selectedDept
? `${selectedDept.name} 下新建子部门`
: `${selectedOrg?.name} 下新建部门`
}
open={deptModalOpen}
onCancel={() => setDeptModalOpen(false)}
onOk={() => deptForm.submit()}
>
<Form form={deptForm} onFinish={handleCreateDept} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入部门名称' }]}
>
<Input />
</Form.Item>
<Form.Item name="code" label="编码">
<Input />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
{/* Position Modal */}
<Modal
title={`${selectedDept?.name} 下新建岗位`}
open={positionModalOpen}
onCancel={() => setPositionModalOpen(false)}
onOk={() => positionForm.submit()}
>
<Form form={positionForm} onFinish={handleCreatePosition} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="name"
label="岗位名称"
rules={[{ required: true, message: '请输入岗位名称' }]}
>
<Input />
</Form.Item>
<Form.Item name="code" label="编码">
<Input />
</Form.Item>
<Form.Item name="level" label="级别" initialValue={1}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
// --- Helpers ---
function findOrgInTree(
tree: OrganizationInfo[],
id: string,
): OrganizationInfo | null {
for (const item of tree) {
if (item.id === id) return item;
const found = findOrgInTree(item.children, id);
if (found) return found;
}
return null;
}
function findDeptInTree(
tree: DepartmentInfo[],
id: string,
): DepartmentInfo | null {
for (const item of tree) {
if (item.id === id) return item;
const found = findDeptInTree(item.children, id);
if (found) return found;
}
return null;
}

View File

@@ -0,0 +1,342 @@
import { useEffect, useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Tag,
message,
Upload,
Modal,
Input,
Drawer,
Descriptions,
Popconfirm,
Form,
theme,
} from 'antd';
import {
UploadOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
CloudDownloadOutlined,
DeleteOutlined,
ReloadOutlined,
AppstoreOutlined,
HeartOutlined,
} from '@ant-design/icons';
import type { PluginInfo, PluginStatus } from '../api/plugins';
import {
listPlugins,
uploadPlugin,
installPlugin,
enablePlugin,
disablePlugin,
uninstallPlugin,
purgePlugin,
getPluginHealth,
} from '../api/plugins';
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
uploaded: { color: '#64748B', label: '已上传' },
installed: { color: '#2563EB', label: '已安装' },
enabled: { color: '#059669', label: '已启用' },
running: { color: '#059669', label: '运行中' },
disabled: { color: '#DC2626', label: '已禁用' },
uninstalled: { color: '#9333EA', label: '已卸载' },
};
export default function PluginAdmin() {
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [uploadModalOpen, setUploadModalOpen] = useState(false);
const [manifestText, setManifestText] = useState('');
const [wasmFile, setWasmFile] = useState<File | null>(null);
const [detailPlugin, setDetailPlugin] = useState<PluginInfo | null>(null);
const [healthDetail, setHealthDetail] = useState<Record<string, unknown> | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const { token } = theme.useToken();
const fetchPlugins = useCallback(async (p = page) => {
setLoading(true);
try {
const result = await listPlugins(p);
setPlugins(result.data);
setTotal(result.total);
} catch {
message.error('加载插件列表失败');
}
setLoading(false);
}, [page]);
useEffect(() => {
fetchPlugins();
}, [fetchPlugins]);
const handleUpload = async () => {
if (!wasmFile || !manifestText.trim()) {
message.warning('请选择 WASM 文件并填写 Manifest');
return;
}
try {
await uploadPlugin(wasmFile, manifestText);
message.success('插件上传成功');
setUploadModalOpen(false);
setWasmFile(null);
setManifestText('');
fetchPlugins();
} catch {
message.error('插件上传失败');
}
};
const handleAction = async (id: string, action: () => Promise<PluginInfo>, label: string) => {
setActionLoading(id);
try {
await action();
message.success(`${label}成功`);
fetchPlugins();
if (detailPlugin?.id === id) {
setDetailPlugin(null);
}
} catch {
message.error(`${label}失败`);
}
setActionLoading(null);
};
const handleHealthCheck = async (id: string) => {
try {
const result = await getPluginHealth(id);
setHealthDetail(result.details);
} catch {
message.error('健康检查失败');
}
};
const getActions = (record: PluginInfo) => {
const id = record.id;
const btns: React.ReactNode[] = [];
switch (record.status) {
case 'uploaded':
btns.push(
<Button
key="install"
size="small"
icon={<CloudDownloadOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => installPlugin(id), '安装')}
>
</Button>,
);
break;
case 'installed':
btns.push(
<Button
key="enable"
size="small"
type="primary"
icon={<PlayCircleOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => enablePlugin(id), '启用')}
>
</Button>,
);
break;
case 'enabled':
case 'running':
btns.push(
<Button
key="disable"
size="small"
danger
icon={<PauseCircleOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => disablePlugin(id), '停用')}
>
</Button>,
);
break;
case 'disabled':
btns.push(
<Button
key="uninstall"
size="small"
icon={<DeleteOutlined />}
loading={actionLoading === id}
onClick={() => handleAction(id, () => uninstallPlugin(id), '卸载')}
>
</Button>,
);
break;
}
return btns;
};
const columns = [
{ title: '名称', dataIndex: 'name', key: 'name', width: 180 },
{ title: '版本', dataIndex: 'version', key: 'version', width: 80 },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: PluginStatus) => {
const cfg = STATUS_CONFIG[status] || { color: '#64748B', label: status };
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{ title: '作者', dataIndex: 'author', key: 'author', width: 120 },
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{
title: '操作',
key: 'action',
width: 320,
render: (_: unknown, record: PluginInfo) => (
<Space size="small">
{getActions(record)}
<Button size="small" onClick={() => setDetailPlugin(record)}>
</Button>
<Popconfirm
title="确定要清除该插件记录吗?"
onConfirm={() => handleAction(record.id, async () => { await purgePlugin(record.id); return record; }, '清除')}
>
<Button size="small" danger disabled={record.status !== 'uninstalled'}>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Space>
<Button icon={<UploadOutlined />} type="primary" onClick={() => setUploadModalOpen(true)}>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => fetchPlugins()}>
</Button>
</Space>
</div>
<Table
columns={columns}
dataSource={plugins}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => setPage(p),
showTotal: (t) => `${t} 个插件`,
}}
/>
<Modal
title="上传插件"
open={uploadModalOpen}
onOk={handleUpload}
onCancel={() => setUploadModalOpen(false)}
okText="上传"
width={600}
>
<Form layout="vertical">
<Form.Item label="WASM 文件" required>
<Upload
beforeUpload={(file) => {
setWasmFile(file);
return false;
}}
maxCount={1}
accept=".wasm"
fileList={wasmFile ? [wasmFile as unknown as Parameters<typeof Upload>[0]] : []}
onRemove={() => setWasmFile(null)}
>
<Button icon={<UploadOutlined />}> WASM </Button>
</Upload>
</Form.Item>
<Form.Item label="Manifest (TOML)" required>
<Input.TextArea
rows={12}
value={manifestText}
onChange={(e) => setManifestText(e.target.value)}
placeholder="[metadata]
id = &quot;my-plugin&quot;
name = &quot;我的插件&quot;
version = &quot;0.1.0&quot;"
/>
</Form.Item>
</Form>
</Modal>
<Drawer
title={detailPlugin ? `插件详情: ${detailPlugin.name}` : '插件详情'}
open={!!detailPlugin}
onClose={() => {
setDetailPlugin(null);
setHealthDetail(null);
}}
width={500}
>
{detailPlugin && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="ID">{detailPlugin.id}</Descriptions.Item>
<Descriptions.Item label="名称">{detailPlugin.name}</Descriptions.Item>
<Descriptions.Item label="版本">{detailPlugin.version}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={STATUS_CONFIG[detailPlugin.status]?.color}>
{STATUS_CONFIG[detailPlugin.status]?.label || detailPlugin.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="作者">{detailPlugin.author || '-'}</Descriptions.Item>
<Descriptions.Item label="描述">{detailPlugin.description || '-'}</Descriptions.Item>
<Descriptions.Item label="安装时间">{detailPlugin.installed_at || '-'}</Descriptions.Item>
<Descriptions.Item label="启用时间">{detailPlugin.enabled_at || '-'}</Descriptions.Item>
<Descriptions.Item label="实体数量">{detailPlugin.entities.length}</Descriptions.Item>
</Descriptions>
)}
<div style={{ marginTop: 16 }}>
<Button
icon={<HeartOutlined />}
onClick={() => detailPlugin && handleHealthCheck(detailPlugin.id)}
style={{ marginBottom: 8 }}
>
</Button>
{healthDetail && (
<pre
style={{
background: token.colorBgContainer,
padding: 12,
borderRadius: 6,
fontSize: 12,
overflow: 'auto',
}}
>
{JSON.stringify(healthDetail, null, 2)}
</pre>
)}
</div>
</Drawer>
</div>
);
}

View File

@@ -0,0 +1,256 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
InputNumber,
DatePicker,
Switch,
Select,
Tag,
message,
Popconfirm,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, ReloadOutlined } from '@ant-design/icons';
import {
listPluginData,
createPluginData,
updatePluginData,
deletePluginData,
} from '../api/pluginData';
import { getPluginSchema } from '../api/plugins';
interface FieldDef {
name: string;
field_type: string;
required: boolean;
display_name?: string;
ui_widget?: string;
options?: { label: string; value: string }[];
}
interface EntitySchema {
name: string;
display_name: string;
fields: FieldDef[];
}
export default function PluginCRUDPage() {
const { pluginId, entityName } = useParams<{ pluginId: string; entityName: string }>();
const [records, setRecords] = useState<Record<string, unknown>[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [fields, setFields] = useState<FieldDef[]>([]);
const [displayName, setDisplayName] = useState(entityName || '');
const [modalOpen, setModalOpen] = useState(false);
const [editRecord, setEditRecord] = useState<Record<string, unknown> | null>(null);
const [form] = Form.useForm();
// 加载 schema
useEffect(() => {
if (!pluginId) return;
getPluginSchema(pluginId)
.then((schema) => {
const entities = (schema as { entities?: EntitySchema[] }).entities || [];
const entity = entities.find((e) => e.name === entityName);
if (entity) {
setFields(entity.fields);
setDisplayName(entity.display_name || entityName || '');
}
})
.catch(() => {
// schema 加载失败时仍可使用
});
}, [pluginId, entityName]);
const fetchData = useCallback(async (p = page) => {
if (!pluginId || !entityName) return;
setLoading(true);
try {
const result = await listPluginData(pluginId, entityName, p);
setRecords(result.data.map((r) => ({ ...r.data, _id: r.id, _version: r.version })));
setTotal(result.total);
} catch {
message.error('加载数据失败');
}
setLoading(false);
}, [pluginId, entityName, page]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleSubmit = async (values: Record<string, unknown>) => {
if (!pluginId || !entityName) return;
// 去除内部字段
const { _id, _version, ...data } = values as Record<string, unknown> & { _id?: string; _version?: number };
try {
if (editRecord) {
await updatePluginData(
pluginId,
entityName,
editRecord._id as string,
data,
editRecord._version as number,
);
message.success('更新成功');
} else {
await createPluginData(pluginId, entityName, data);
message.success('创建成功');
}
setModalOpen(false);
setEditRecord(null);
fetchData();
} catch {
message.error('操作失败');
}
};
const handleDelete = async (record: Record<string, unknown>) => {
if (!pluginId || !entityName) return;
try {
await deletePluginData(pluginId, entityName, record._id as string);
message.success('删除成功');
fetchData();
} catch {
message.error('删除失败');
}
};
// 动态生成列
const columns = [
...fields.slice(0, 5).map((f) => ({
title: f.display_name || f.name,
dataIndex: f.name,
key: f.name,
ellipsis: true,
render: (val: unknown) => {
if (typeof val === 'boolean') return val ? <Tag color="green"></Tag> : <Tag></Tag>;
return String(val ?? '-');
},
})),
{
title: '操作',
key: 'action',
width: 150,
render: (_: unknown, record: Record<string, unknown>) => (
<Space size="small">
<Button
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditRecord(record);
form.setFieldsValue(record);
setModalOpen(true);
}}
>
</Button>
<Popconfirm title="确定删除?" onConfirm={() => handleDelete(record)}>
<Button size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</Space>
),
},
];
// 动态生成表单字段
const renderFormField = (field: FieldDef) => {
const widget = field.ui_widget || field.field_type;
switch (widget) {
case 'number':
case 'integer':
case 'float':
case 'decimal':
return <InputNumber style={{ width: '100%' }} />;
case 'boolean':
return <Switch />;
case 'date':
case 'datetime':
return <DatePicker showTime={widget === 'datetime'} style={{ width: '100%' }} />;
case 'select':
return (
<Select>
{(field.options || []).map((opt) => (
<Select.Option key={String(opt.value)} value={opt.value}>
{opt.label}
</Select.Option>
))}
</Select>
);
default:
return <Input />;
}
};
return (
<div style={{ padding: 24 }}>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0 }}>{displayName}</h2>
<Space>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
setEditRecord(null);
form.resetFields();
setModalOpen(true);
}}
>
</Button>
<Button icon={<ReloadOutlined />} onClick={() => fetchData()}>
</Button>
</Space>
</div>
<Table
columns={columns}
dataSource={records}
rowKey="_id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => setPage(p),
showTotal: (t) => `${t}`,
}}
/>
<Modal
title={editRecord ? '编辑' : '新增'}
open={modalOpen}
onCancel={() => {
setModalOpen(false);
setEditRecord(null);
}}
onOk={() => form.submit()}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
{fields.map((field) => (
<Form.Item
key={field.name}
name={field.name}
label={field.display_name || field.name}
rules={field.required ? [{ required: true, message: `请输入${field.display_name || field.name}` }] : []}
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
>
{renderFormField(field)}
</Form.Item>
))}
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,373 @@
import { useEffect, useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
Tag,
Popconfirm,
Checkbox,
message,
theme,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import {
listRoles,
createRole,
updateRole,
deleteRole,
assignPermissions,
getRolePermissions,
listPermissions,
type RoleInfo,
type PermissionInfo,
} from '../api/roles';
export default function Roles() {
const [roles, setRoles] = useState<RoleInfo[]>([]);
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
const [loading, setLoading] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editRole, setEditRole] = useState<RoleInfo | null>(null);
const [permModalOpen, setPermModalOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<RoleInfo | null>(null);
const [selectedPermIds, setSelectedPermIds] = useState<string[]>([]);
const [form] = Form.useForm();
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const fetchRoles = useCallback(async () => {
setLoading(true);
try {
const result = await listRoles();
setRoles(result.data);
} catch {
message.error('加载角色失败');
}
setLoading(false);
}, []);
const fetchPermissions = useCallback(async () => {
try {
setPermissions(await listPermissions());
} catch {
// 静默处理
}
}, []);
useEffect(() => {
fetchRoles();
fetchPermissions();
}, [fetchRoles, fetchPermissions]);
const handleCreate = async (values: {
name: string;
code: string;
description?: string;
}) => {
try {
if (editRole) {
await updateRole(editRole.id, { ...values, version: editRole.version });
message.success('角色更新成功');
} else {
await createRole(values);
message.success('角色创建成功');
}
setCreateModalOpen(false);
setEditRole(null);
form.resetFields();
fetchRoles();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDelete = async (id: string) => {
try {
await deleteRole(id);
message.success('角色已删除');
fetchRoles();
} catch {
message.error('删除失败');
}
};
const openPermModal = async (role: RoleInfo) => {
setSelectedRole(role);
try {
const rolePerms = await getRolePermissions(role.id);
setSelectedPermIds(rolePerms.map((p) => p.id));
} catch {
setSelectedPermIds([]);
}
setPermModalOpen(true);
};
const savePermissions = async () => {
if (!selectedRole) return;
try {
await assignPermissions(selectedRole.id, selectedPermIds);
message.success('权限分配成功');
setPermModalOpen(false);
} catch {
message.error('权限分配失败');
}
};
const openEditModal = (role: RoleInfo) => {
setEditRole(role);
form.setFieldsValue({
name: role.name,
code: role.code,
description: role.description,
});
setCreateModalOpen(true);
};
const openCreateModal = () => {
setEditRole(null);
form.resetFields();
setCreateModalOpen(true);
};
const closeCreateModal = () => {
setCreateModalOpen(false);
setEditRole(null);
form.resetFields();
};
const columns = [
{
title: '角色名称',
dataIndex: 'name',
key: 'name',
render: (v: string, record: RoleInfo) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: record.is_system
? 'linear-gradient(135deg, #4F46E5, #818CF8)'
: isDark ? '#1E293B' : '#F1F5F9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: record.is_system ? '#fff' : isDark ? '#94A3B8' : '#64748B',
fontSize: 14,
}}
>
<SafetyCertificateOutlined />
</div>
<span style={{ fontWeight: 500 }}>{v}</span>
</div>
),
},
{
title: '编码',
dataIndex: 'code',
key: 'code',
render: (v: string) => (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
border: 'none',
color: isDark ? '#94A3B8' : '#64748B',
fontFamily: 'monospace',
fontSize: 12,
}}>
{v}
</Tag>
),
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
ellipsis: true,
render: (v: string | undefined) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8' }}>{v || '-'}</span>
),
},
{
title: '类型',
dataIndex: 'is_system',
key: 'is_system',
width: 100,
render: (v: boolean) => (
<Tag
style={{
color: v ? '#4F46E5' : (isDark ? '#94A3B8' : '#64748B'),
background: v ? '#EEF2FF' : (isDark ? '#1E293B' : '#F1F5F9'),
border: 'none',
fontWeight: 500,
}}
>
{v ? '系统' : '自定义'}
</Tag>
),
},
{
title: '操作',
key: 'actions',
width: 180,
render: (_: unknown, record: RoleInfo) => (
<Space size={4}>
<Button
size="small"
type="text"
icon={<SafetyCertificateOutlined />}
onClick={() => openPermModal(record)}
style={{ color: '#4F46E5' }}
>
</Button>
{!record.is_system && (
<>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
/>
<Popconfirm
title="确定删除此角色?"
onConfirm={() => handleDelete(record.id)}
>
<Button
size="small"
type="text"
icon={<DeleteOutlined />}
danger
/>
</Popconfirm>
</>
)}
</Space>
),
},
];
const groupedPermissions = permissions.reduce<Record<string, PermissionInfo[]>>(
(acc, p) => {
if (!acc[p.resource]) acc[p.resource] = [];
acc[p.resource].push(p);
return acc;
},
{},
);
return (
<div>
{/* 页面标题和工具栏 */}
<div className="erp-page-header">
<div>
<h4></h4>
<div className="erp-page-subtitle"></div>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>
</Button>
</div>
{/* 表格容器 */}
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
overflow: 'hidden',
}}>
<Table
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t} 条记录` }}
/>
</div>
{/* 新建/编辑角色弹窗 */}
<Modal
title={editRole ? '编辑角色' : '新建角色'}
open={createModalOpen}
onCancel={closeCreateModal}
onOk={() => form.submit()}
width={480}
>
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="name"
label="名称"
rules={[{ required: true, message: '请输入角色名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="code"
label="编码"
rules={[{ required: true, message: '请输入角色编码' }]}
>
<Input disabled={!!editRole} />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
{/* 权限分配弹窗 */}
<Modal
title={`权限分配 - ${selectedRole?.name || ''}`}
open={permModalOpen}
onCancel={() => setPermModalOpen(false)}
onOk={savePermissions}
width={600}
>
<div style={{ marginTop: 8 }}>
{Object.entries(groupedPermissions).map(([resource, perms]) => (
<div
key={resource}
style={{
marginBottom: 16,
padding: 16,
borderRadius: 10,
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#F8FAFC',
}}
>
<div style={{
fontWeight: 600,
marginBottom: 12,
textTransform: 'capitalize',
color: isDark ? '#E2E8F0' : '#334155',
fontSize: 14,
}}>
{resource}
</div>
<Checkbox.Group
value={selectedPermIds}
onChange={(values) => setSelectedPermIds(values as string[])}
style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}
>
{perms.map((p) => (
<Checkbox
key={p.id}
value={p.id}
style={{ marginRight: 0 }}
>
{p.name}
</Checkbox>
))}
</Checkbox.Group>
</div>
))}
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { Tabs } from 'antd';
import {
BookOutlined,
GlobalOutlined,
MenuOutlined,
NumberOutlined,
SettingOutlined,
BgColorsOutlined,
AuditOutlined,
LockOutlined,
} from '@ant-design/icons';
import DictionaryManager from './settings/DictionaryManager';
import LanguageManager from './settings/LanguageManager';
import MenuConfig from './settings/MenuConfig';
import NumberingRules from './settings/NumberingRules';
import SystemSettings from './settings/SystemSettings';
import ThemeSettings from './settings/ThemeSettings';
import AuditLogViewer from './settings/AuditLogViewer';
import ChangePassword from './settings/ChangePassword';
const Settings: React.FC = () => {
return (
<div>
<div className="erp-page-header" style={{ borderBottom: 'none', marginBottom: 0, paddingBottom: 8 }}>
<div>
<h4></h4>
<div className="erp-page-subtitle"></div>
</div>
</div>
<Tabs
defaultActiveKey="dictionaries"
style={{ marginTop: 8 }}
items={[
{
key: 'dictionaries',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<BookOutlined style={{ fontSize: 14 }} />
</span>
),
children: <DictionaryManager />,
},
{
key: 'languages',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<GlobalOutlined style={{ fontSize: 14 }} />
</span>
),
children: <LanguageManager />,
},
{
key: 'menus',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<MenuOutlined style={{ fontSize: 14 }} />
</span>
),
children: <MenuConfig />,
},
{
key: 'numbering',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<NumberOutlined style={{ fontSize: 14 }} />
</span>
),
children: <NumberingRules />,
},
{
key: 'settings',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<SettingOutlined style={{ fontSize: 14 }} />
</span>
),
children: <SystemSettings />,
},
{
key: 'theme',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<BgColorsOutlined style={{ fontSize: 14 }} />
</span>
),
children: <ThemeSettings />,
},
{
key: 'audit-log',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<AuditOutlined style={{ fontSize: 14 }} />
</span>
),
children: <AuditLogViewer />,
},
{
key: 'change-password',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<LockOutlined style={{ fontSize: 14 }} />
</span>
),
children: <ChangePassword />,
},
]}
/>
</div>
);
};
export default Settings;

View File

@@ -0,0 +1,473 @@
import { useEffect, useState, useCallback } from 'react';
import {
Table,
Button,
Space,
Modal,
Form,
Input,
Tag,
Popconfirm,
Checkbox,
message,
theme,
} from 'antd';
import {
PlusOutlined,
SearchOutlined,
EditOutlined,
DeleteOutlined,
UserOutlined,
SafetyCertificateOutlined,
StopOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import {
listUsers,
createUser,
updateUser,
deleteUser,
assignRoles,
type CreateUserRequest,
type UpdateUserRequest,
} from '../api/users';
import { listRoles, type RoleInfo } from '../api/roles';
import type { UserInfo } from '../api/auth';
const STATUS_COLOR_MAP: Record<string, string> = {
active: '#059669',
disabled: '#DC2626',
locked: '#D97706',
};
const STATUS_BG_MAP: Record<string, string> = {
active: '#ECFDF5',
disabled: '#FEF2F2',
locked: '#FFFBEB',
};
const STATUS_LABEL_MAP: Record<string, string> = {
active: '正常',
disabled: '禁用',
locked: '锁定',
};
export default function Users() {
const [users, setUsers] = useState<UserInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState('');
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editUser, setEditUser] = useState<UserInfo | null>(null);
const [roleModalOpen, setRoleModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null);
const [allRoles, setAllRoles] = useState<RoleInfo[]>([]);
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
const [form] = Form.useForm();
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const fetchUsers = useCallback(async (p = page) => {
setLoading(true);
try {
const result = await listUsers(p, 20, searchText);
setUsers(result.data);
setTotal(result.total);
} catch {
message.error('加载用户列表失败');
}
setLoading(false);
}, [page, searchText]);
const fetchRoles = useCallback(async () => {
try {
const result = await listRoles();
setAllRoles(result.data);
} catch {
// 静默处理
}
}, []);
useEffect(() => {
fetchUsers();
fetchRoles();
}, [fetchUsers, fetchRoles]);
const handleCreateOrEdit = async (values: {
username: string;
password?: string;
display_name?: string;
email?: string;
phone?: string;
}) => {
try {
if (editUser) {
const req: UpdateUserRequest = {
display_name: values.display_name,
email: values.email,
phone: values.phone,
version: editUser.version,
};
await updateUser(editUser.id, req);
message.success('用户更新成功');
} else {
const req: CreateUserRequest = {
username: values.username,
password: values.password ?? '',
display_name: values.display_name,
email: values.email,
phone: values.phone,
};
await createUser(req);
message.success('用户创建成功');
}
setCreateModalOpen(false);
setEditUser(null);
form.resetFields();
fetchUsers();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDelete = async (id: string) => {
try {
await deleteUser(id);
message.success('用户已删除');
fetchUsers();
} catch {
message.error('删除失败');
}
};
const handleToggleStatus = async (id: string, status: string) => {
try {
const user = users.find(u => u.id === id);
if (!user) return;
await updateUser(id, { status, version: user.version });
message.success(status === 'disabled' ? '用户已禁用' : '用户已启用');
fetchUsers();
} catch {
message.error('状态更新失败');
}
};
const handleAssignRoles = async () => {
if (!selectedUser) return;
try {
await assignRoles(selectedUser.id, selectedRoleIds);
message.success('角色分配成功');
setRoleModalOpen(false);
fetchUsers();
} catch {
message.error('角色分配失败');
}
};
const openCreateModal = () => {
setEditUser(null);
form.resetFields();
setCreateModalOpen(true);
};
const openEditModal = (user: UserInfo) => {
setEditUser(user);
form.setFieldsValue({
username: user.username,
display_name: user.display_name,
email: user.email,
phone: user.phone,
});
setCreateModalOpen(true);
};
const closeCreateModal = () => {
setCreateModalOpen(false);
setEditUser(null);
form.resetFields();
};
const openRoleModal = (user: UserInfo) => {
setSelectedUser(user);
setSelectedRoleIds(user.roles.map((r) => r.id));
setRoleModalOpen(true);
};
const filteredUsers = users;
const columns = [
{
title: '用户',
dataIndex: 'username',
key: 'username',
render: (v: string, record: UserInfo) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: 8,
background: 'linear-gradient(135deg, #4F46E5, #818CF8)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 13,
fontWeight: 600,
}}
>
{(record.display_name?.[0] || v?.[0] || 'U').toUpperCase()}
</div>
<div>
<div style={{ fontWeight: 500, fontSize: 14 }}>{v}</div>
{record.display_name && (
<div style={{ fontSize: 12, color: isDark ? '#64748B' : '#94A3B8' }}>
{record.display_name}
</div>
)}
</div>
</div>
),
},
{
title: '邮箱',
dataIndex: 'email',
key: 'email',
render: (v: string | undefined) => v || '-',
},
{
title: '电话',
dataIndex: 'phone',
key: 'phone',
render: (v: string | undefined) => v || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => (
<Tag
style={{
color: STATUS_COLOR_MAP[status] || '#64748B',
background: STATUS_BG_MAP[status] || '#F1F5F9',
border: 'none',
fontWeight: 500,
}}
>
{STATUS_LABEL_MAP[status] || status}
</Tag>
),
},
{
title: '角色',
dataIndex: 'roles',
key: 'roles',
render: (roles: RoleInfo[]) =>
roles.length > 0
? roles.map((r) => (
<Tag key={r.id} style={{
background: isDark ? '#1E293B' : '#F1F5F9',
border: 'none',
color: isDark ? '#CBD5E1' : '#475569',
}}>
{r.name}
</Tag>
))
: <span style={{ color: isDark ? '#475569' : '#CBD5E1' }}>-</span>,
},
{
title: '操作',
key: 'actions',
width: 240,
render: (_: unknown, record: UserInfo) => (
<Space size={4}>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => openEditModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
/>
<Button
size="small"
type="text"
icon={<SafetyCertificateOutlined />}
onClick={() => openRoleModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
/>
{record.status === 'active' ? (
<Popconfirm
title="确定禁用此用户?"
onConfirm={() => handleToggleStatus(record.id, 'disabled')}
>
<Button
size="small"
type="text"
icon={<StopOutlined />}
danger
/>
</Popconfirm>
) : (
<Button
size="small"
type="text"
icon={<CheckCircleOutlined />}
onClick={() => handleToggleStatus(record.id, 'active')}
style={{ color: '#059669' }}
/>
)}
<Popconfirm
title="确定删除此用户?"
onConfirm={() => handleDelete(record.id)}
>
<Button
size="small"
type="text"
icon={<DeleteOutlined />}
danger
/>
</Popconfirm>
</Space>
),
},
];
return (
<div>
{/* 页面标题和工具栏 */}
<div className="erp-page-header">
<div>
<h4></h4>
<div className="erp-page-subtitle"></div>
</div>
<Space size={8}>
<Input
placeholder="搜索用户名..."
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
allowClear
style={{ width: 220, borderRadius: 8 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openCreateModal}
>
</Button>
</Space>
</div>
{/* 表格容器 */}
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
overflow: 'hidden',
}}>
<Table
columns={columns}
dataSource={filteredUsers}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchUsers(p);
},
showTotal: (t) => `${t} 条记录`,
style: { padding: '12px 16px', margin: 0 },
}}
/>
</div>
{/* 新建/编辑用户弹窗 */}
<Modal
title={editUser ? '编辑用户' : '新建用户'}
open={createModalOpen}
onCancel={closeCreateModal}
onOk={() => form.submit()}
width={480}
>
<Form form={form} onFinish={handleCreateOrEdit} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input prefix={<UserOutlined style={{ color: '#94A3B8' }} />} disabled={!!editUser} />
</Form.Item>
{!editUser && (
<Form.Item
name="password"
label="密码"
rules={[
{ required: true, message: '请输入密码' },
{ min: 6, message: '密码至少6位' },
]}
>
<Input.Password />
</Form.Item>
)}
<Form.Item name="display_name" label="显示名">
<Input />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
>
<Input type="email" />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
</Form.Item>
</Form>
</Modal>
{/* 角色分配弹窗 */}
<Modal
title={`分配角色 - ${selectedUser?.username || ''}`}
open={roleModalOpen}
onCancel={() => setRoleModalOpen(false)}
onOk={handleAssignRoles}
width={480}
>
<div style={{ marginTop: 8 }}>
<Checkbox.Group
value={selectedRoleIds}
onChange={(values) => setSelectedRoleIds(values as string[])}
style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
>
{allRoles.map((r) => (
<div
key={r.id}
style={{
padding: '10px 14px',
borderRadius: 8,
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#F8FAFC',
}}
>
<Checkbox value={r.id}>
<span style={{ fontWeight: 500 }}>{r.name}</span>
<span style={{ color: isDark ? '#475569' : '#94A3B8', marginLeft: 8, fontSize: 12 }}>
{r.code}
</span>
</Checkbox>
</div>
))}
</Checkbox.Group>
</div>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import { useState } from 'react';
import { Tabs } from 'antd';
import { PartitionOutlined, FileSearchOutlined, CheckSquareOutlined, MonitorOutlined } from '@ant-design/icons';
import ProcessDefinitions from './workflow/ProcessDefinitions';
import PendingTasks from './workflow/PendingTasks';
import CompletedTasks from './workflow/CompletedTasks';
import InstanceMonitor from './workflow/InstanceMonitor';
export default function Workflow() {
const [activeKey, setActiveKey] = useState('definitions');
return (
<div>
<div className="erp-page-header" style={{ borderBottom: 'none', marginBottom: 0, paddingBottom: 8 }}>
<div>
<h4></h4>
<div className="erp-page-subtitle"></div>
</div>
</div>
<Tabs
activeKey={activeKey}
onChange={setActiveKey}
style={{ marginTop: 8 }}
items={[
{
key: 'definitions',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<PartitionOutlined style={{ fontSize: 14 }} />
</span>
),
children: <ProcessDefinitions />,
},
{
key: 'pending',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FileSearchOutlined style={{ fontSize: 14 }} />
</span>
),
children: <PendingTasks />,
},
{
key: 'completed',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<CheckSquareOutlined style={{ fontSize: 14 }} />
</span>
),
children: <CompletedTasks />,
},
{
key: 'instances',
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<MonitorOutlined style={{ fontSize: 14 }} />
</span>
),
children: <InstanceMonitor />,
},
]}
/>
</div>
);
}

View File

@@ -0,0 +1,194 @@
import { useEffect, useState, useCallback } from 'react';
import { Table, Button, Modal, Form, Input, Select, message, theme, Tag } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { listTemplates, createTemplate, type MessageTemplateInfo } from '../../api/messageTemplates';
const channelMap: Record<string, { label: string; color: string }> = {
in_app: { label: '站内', color: '#4F46E5' },
email: { label: '邮件', color: '#059669' },
sms: { label: '短信', color: '#D97706' },
wechat: { label: '微信', color: '#7C3AED' },
};
export default function MessageTemplates() {
const [data, setData] = useState<MessageTemplateInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm();
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const fetchData = useCallback(async (p = page) => {
setLoading(true);
try {
const result = await listTemplates(p, 20);
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载模板列表失败');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
fetchData(1);
}, [fetchData]);
const handleCreate = async () => {
try {
const values = await form.validateFields();
await createTemplate(values);
message.success('模板创建成功');
setModalOpen(false);
form.resetFields();
fetchData();
} catch {
message.error('创建失败');
}
};
const columns: ColumnsType<MessageTemplateInfo> = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
},
{
title: '编码',
dataIndex: 'code',
key: 'code',
render: (v: string) => (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
border: 'none',
color: isDark ? '#94A3B8' : '#64748B',
fontFamily: 'monospace',
fontSize: 12,
}}>
{v}
</Tag>
),
},
{
title: '通道',
dataIndex: 'channel',
key: 'channel',
width: 90,
render: (c: string) => {
const info = channelMap[c] || { label: c, color: '#64748B' };
return (
<Tag style={{
background: info.color + '15',
border: 'none',
color: info.color,
fontWeight: 500,
}}>
{info.label}
</Tag>
);
},
},
{
title: '标题模板',
dataIndex: 'title_template',
key: 'title_template',
ellipsis: true,
},
{
title: '语言',
dataIndex: 'language',
key: 'language',
width: 80,
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>{v}</span>
),
},
];
return (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
{total}
</span>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
</Button>
</div>
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
overflow: 'hidden',
}}>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => { setPage(p); fetchData(p); },
showTotal: (t) => `${t} 条记录`,
}}
/>
</div>
<Modal
title="新建消息模板"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields(); }}
width={520}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入名称' }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请输入编码' }]}>
<Input />
</Form.Item>
<Form.Item name="channel" label="通道" initialValue="in_app">
<Select options={[
{ value: 'in_app', label: '站内' },
{ value: 'email', label: '邮件' },
{ value: 'sms', label: '短信' },
{ value: 'wechat', label: '微信' },
]} />
</Form.Item>
<Form.Item name="title_template" label="标题模板" rules={[{ required: true, message: '请输入标题模板' }]}>
<Input placeholder="支持 {{variable}} 变量插值" />
</Form.Item>
<Form.Item name="body_template" label="内容模板" rules={[{ required: true, message: '请输入内容模板' }]}>
<Input.TextArea rows={4} placeholder="支持 {{variable}} 变量插值" />
</Form.Item>
<Form.Item name="language" label="语言" initialValue="zh-CN">
<Select options={[
{ value: 'zh-CN', label: '中文' },
{ value: 'en-US', label: '英文' },
]} />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,248 @@
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Typography, message, theme } from 'antd';
import { CheckOutlined, DeleteOutlined, EyeOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { listMessages, markRead, markAllRead, deleteMessage, type MessageInfo, type MessageQuery } from '../../api/messages';
const { Paragraph } = Typography;
interface Props {
queryFilter?: MessageQuery;
}
const priorityStyles: Record<string, { bg: string; color: string; text: string }> = {
urgent: { bg: '#FEF2F2', color: '#DC2626', text: '紧急' },
important: { bg: '#FFFBEB', color: '#D97706', text: '重要' },
normal: { bg: '#EEF2FF', color: '#4F46E5', text: '普通' },
};
export default function NotificationList({ queryFilter }: Props) {
const [data, setData] = useState<MessageInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const fetchData = useCallback(async (p = page, filter?: MessageQuery) => {
setLoading(true);
try {
const result = await listMessages({ page: p, page_size: 20, ...filter });
setData(result.data);
setTotal(result.total);
} catch {
message.error('加载消息列表失败');
} finally {
setLoading(false);
}
}, [page]);
const filterKey = useMemo(() => JSON.stringify(queryFilter), [queryFilter]);
const isFirstRender = useRef(true);
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
fetchData(1, queryFilter);
}
}, [filterKey, fetchData, queryFilter]);
const handleMarkRead = async (id: string) => {
try {
await markRead(id);
fetchData(page, queryFilter);
} catch {
message.error('操作失败');
}
};
const handleMarkAllRead = async () => {
try {
await markAllRead();
fetchData(page, queryFilter);
message.success('已全部标记为已读');
} catch {
message.error('操作失败');
}
};
const handleDelete = async (id: string) => {
try {
await deleteMessage(id);
fetchData(page, queryFilter);
message.success('已删除');
} catch {
message.error('删除失败');
}
};
const showDetail = (record: MessageInfo) => {
Modal.info({
title: record.title,
width: 520,
content: (
<div>
<Paragraph>{record.body}</Paragraph>
<div style={{ marginTop: 8, color: isDark ? '#475569' : '#94A3B8', fontSize: 12 }}>
{record.created_at}
</div>
</div>
),
});
if (!record.is_read) {
handleMarkRead(record.id);
}
};
const columns: ColumnsType<MessageInfo> = [
{
title: '标题',
dataIndex: 'title',
key: 'title',
render: (text: string, record) => (
<span
style={{
fontWeight: record.is_read ? 400 : 600,
cursor: 'pointer',
color: record.is_read ? (isDark ? '#94A3B8' : '#64748B') : 'inherit',
}}
onClick={() => showDetail(record)}
>
{!record.is_read && (
<span style={{
display: 'inline-block',
width: 6,
height: 6,
borderRadius: '50%',
background: '#4F46E5',
marginRight: 8,
}} />
)}
{text}
</span>
),
},
{
title: '优先级',
dataIndex: 'priority',
key: 'priority',
width: 90,
render: (p: string) => {
const info = priorityStyles[p] || { bg: '#F1F5F9', color: '#64748B', text: p };
return (
<Tag style={{
background: info.bg,
border: 'none',
color: info.color,
fontWeight: 500,
}}>
{info.text}
</Tag>
);
},
},
{
title: '发送者',
dataIndex: 'sender_type',
key: 'sender_type',
width: 80,
render: (s: string) => <span style={{ color: isDark ? '#64748B' : '#94A3B8' }}>{s === 'system' ? '系统' : '用户'}</span>,
},
{
title: '状态',
dataIndex: 'is_read',
key: 'is_read',
width: 80,
render: (r: boolean) => (
<Tag style={{
background: r ? (isDark ? '#1E293B' : '#F1F5F9') : '#EEF2FF',
border: 'none',
color: r ? (isDark ? '#64748B' : '#94A3B8') : '#4F46E5',
fontWeight: 500,
}}>
{r ? '已读' : '未读'}
</Tag>
),
},
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>{v}</span>
),
},
{
title: '操作',
key: 'actions',
width: 120,
render: (_: unknown, record) => (
<Space size={4}>
{!record.is_read && (
<Button
type="text"
size="small"
icon={<CheckOutlined />}
onClick={() => handleMarkRead(record.id)}
style={{ color: '#4F46E5' }}
/>
)}
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => showDetail(record)}
style={{ color: isDark ? '#64748B' : '#94A3B8' }}
/>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(record.id)}
/>
</Space>
),
},
];
return (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
{total}
</span>
<Button icon={<CheckOutlined />} onClick={handleMarkAllRead}>
</Button>
</div>
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
overflow: 'hidden',
}}>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => { setPage(p); fetchData(p, queryFilter); },
showTotal: (t) => `${t} 条记录`,
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import { Form, Switch, TimePicker, Button, message, theme } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import client from '../../api/client';
interface PreferencesData {
dnd_enabled: boolean;
dnd_start?: string;
dnd_end?: string;
}
export default function NotificationPreferences() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [dndEnabled, setDndEnabled] = useState(false);
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
useEffect(() => {
form.setFieldsValue({ dnd_enabled: false });
}, [form]);
const handleSave = async () => {
setLoading(true);
try {
const values = await form.validateFields();
const req: PreferencesData = {
dnd_enabled: values.dnd_enabled || false,
dnd_start: values.dnd_range?.[0]?.format('HH:mm'),
dnd_end: values.dnd_range?.[1]?.format('HH:mm'),
};
await client.put('/message-subscriptions', {
dnd_enabled: req.dnd_enabled,
dnd_start: req.dnd_start,
dnd_end: req.dnd_end,
});
message.success('偏好设置已保存');
} catch {
message.error('保存失败');
} finally {
setLoading(false);
}
};
return (
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
padding: 24,
maxWidth: 600,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 20 }}>
<BellOutlined style={{ fontSize: 16, color: '#4F46E5' }} />
<span style={{ fontSize: 15, fontWeight: 600 }}></span>
</div>
<Form form={form} layout="vertical">
<Form.Item name="dnd_enabled" label="免打扰模式" valuePropName="checked">
<Switch onChange={setDndEnabled} />
</Form.Item>
{dndEnabled && (
<Form.Item name="dnd_range" label="免打扰时段">
<TimePicker.RangePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
)}
<Form.Item>
<Button type="primary" onClick={handleSave} loading={loading}>
</Button>
</Form.Item>
</Form>
</div>
);
}

View File

@@ -0,0 +1,206 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Select, Input, Tag, message, theme } from 'antd';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import { listAuditLogs, type AuditLogItem, type AuditLogQuery } from '../../api/auditLogs';
const RESOURCE_TYPE_OPTIONS = [
{ value: 'user', label: '用户' },
{ value: 'role', label: '角色' },
{ value: 'organization', label: '组织' },
{ value: 'department', label: '部门' },
{ value: 'position', label: '岗位' },
{ value: 'process_instance', label: '流程实例' },
{ value: 'dictionary', label: '字典' },
{ value: 'menu', label: '菜单' },
{ value: 'setting', label: '设置' },
{ value: 'numbering_rule', label: '编号规则' },
];
const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = {
create: { bg: '#ECFDF5', color: '#059669', text: '创建' },
update: { bg: '#EEF2FF', color: '#4F46E5', text: '更新' },
delete: { bg: '#FEF2F2', color: '#DC2626', text: '删除' },
};
function formatDateTime(value: string): string {
return new Date(value).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
export default function AuditLogViewer() {
const [logs, setLogs] = useState<AuditLogItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<AuditLogQuery>({ page: 1, page_size: 20 });
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const fetchLogs = useCallback(async (params: AuditLogQuery) => {
setLoading(true);
try {
const result = await listAuditLogs(params);
setLogs(result.data);
setTotal(result.total);
} catch {
message.error('加载审计日志失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchLogs(query);
}, [query, fetchLogs]);
const handleFilterChange = (field: keyof AuditLogQuery, value: string | undefined) => {
setQuery((prev) => ({
...prev,
[field]: value || undefined,
page: 1,
}));
};
const handleTableChange = (pagination: TablePaginationConfig) => {
setQuery((prev) => ({
...prev,
page: pagination.current,
page_size: pagination.pageSize,
}));
};
const columns: ColumnsType<AuditLogItem> = [
{
title: '操作',
dataIndex: 'action',
key: 'action',
width: 100,
render: (action: string) => {
const info = ACTION_STYLES[action] || { bg: '#F1F5F9', color: '#64748B', text: action };
return (
<Tag style={{
background: info.bg,
border: 'none',
color: info.color,
fontWeight: 500,
}}>
{info.text}
</Tag>
);
},
},
{
title: '资源类型',
dataIndex: 'resource_type',
key: 'resource_type',
width: 120,
render: (v: string) => (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
border: 'none',
color: isDark ? '#CBD5E1' : '#475569',
}}>
{v}
</Tag>
),
},
{
title: '资源 ID',
dataIndex: 'resource_id',
key: 'resource_id',
width: 200,
ellipsis: true,
render: (v: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}>
{v}
</span>
),
},
{
title: '操作用户',
dataIndex: 'user_id',
key: 'user_id',
width: 200,
ellipsis: true,
render: (v: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}>
{v}
</span>
),
},
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (value: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
{formatDateTime(value)}
</span>
),
},
];
return (
<div>
{/* 筛选工具栏 */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 12,
marginBottom: 16,
padding: 12,
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 10,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
}}>
<Select
allowClear
placeholder="资源类型"
style={{ width: 160 }}
options={RESOURCE_TYPE_OPTIONS}
value={query.resource_type}
onChange={(value) => handleFilterChange('resource_type', value)}
/>
<Input
allowClear
placeholder="操作用户 ID"
style={{ width: 240 }}
value={query.user_id ?? ''}
onChange={(e) => handleFilterChange('user_id', e.target.value)}
/>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8', marginLeft: 'auto' }}>
{total}
</span>
</div>
{/* 表格 */}
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
overflow: 'hidden',
}}>
<Table
rowKey="id"
columns={columns}
dataSource={logs}
loading={loading}
onChange={handleTableChange}
pagination={{
current: query.page,
pageSize: query.page_size,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}}
scroll={{ x: 900 }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { Form, Input, Button, message, Card, Typography } from 'antd';
import { LockOutlined } from '@ant-design/icons';
import { useAuthStore } from '../../stores/auth';
import { changePassword } from '../../api/auth';
import { useNavigate } from 'react-router-dom';
const { Title } = Typography;
export default function ChangePassword() {
const [messageApi, contextHolder] = message.useMessage();
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
const [form] = Form.useForm();
const onFinish = async (values: {
current_password: string;
new_password: string;
confirm_password: string;
}) => {
if (values.new_password !== values.confirm_password) {
messageApi.error('两次输入的新密码不一致');
return;
}
try {
await changePassword(values.current_password, values.new_password);
messageApi.success('密码修改成功,请重新登录');
await logout();
navigate('/login');
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '密码修改失败';
messageApi.error(errorMsg);
}
};
return (
<Card style={{ maxWidth: 480, margin: '0 auto' }}>
{contextHolder}
<Title level={4} style={{ marginBottom: 24 }}>
</Title>
<Form form={form} layout="vertical" onFinish={onFinish}>
<Form.Item
name="current_password"
label="当前密码"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入当前密码"
/>
</Form.Item>
<Form.Item
name="new_password"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度不能少于6位' },
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入新密码至少6位"
/>
</Form.Item>
<Form.Item
name="confirm_password"
label="确认新密码"
rules={[
{ required: true, message: '请确认新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请再次输入新密码"
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form.Item>
</Form>
</Card>
);
}

View File

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

View File

@@ -0,0 +1,210 @@
import { useEffect, useState, useCallback } from 'react';
import {
Table,
Switch,
Modal,
Form,
Input,
Button,
Space,
Typography,
message,
Card,
} from 'antd';
import { EditOutlined } from '@ant-design/icons';
import {
listLanguages,
updateLanguage,
type LanguageInfo,
} from '../../api/languages';
// --- Component ---
export default function LanguageManager() {
const [languages, setLanguages] = useState<LanguageInfo[]>([]);
const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingLang, setEditingLang] = useState<LanguageInfo | null>(null);
const [editForm] = Form.useForm();
const fetchLanguages = useCallback(async () => {
setLoading(true);
try {
const result = await listLanguages();
setLanguages(result);
} catch {
message.error('加载语言列表失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchLanguages();
}, [fetchLanguages]);
// --- Enable / Disable Toggle ---
const handleToggle = async (record: LanguageInfo, enabled: boolean) => {
try {
await updateLanguage(record.code, { enabled });
setLanguages((prev) =>
prev.map((lang) =>
lang.code === record.code ? { ...lang, enabled } : lang,
),
);
message.success(enabled ? '已启用' : '已禁用');
} catch {
message.error('操作失败');
}
};
// --- Edit Modal ---
const openEdit = (lang: LanguageInfo) => {
setEditingLang(lang);
editForm.setFieldsValue({
name: lang.name,
translations: lang.translations
? Object.entries(lang.translations)
.map(([key, value]) => `${key}=${value}`)
.join('\n')
: '',
});
setEditModalOpen(true);
};
const closeEdit = () => {
setEditModalOpen(false);
setEditingLang(null);
editForm.resetFields();
};
const handleEditSubmit = async (values: { name: string; translations: string }) => {
if (!editingLang) return;
const translations: Record<string, string> = {};
if (values.translations?.trim()) {
for (const line of values.translations.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex === -1) continue;
const key = trimmed.slice(0, eqIndex).trim();
const val = trimmed.slice(eqIndex + 1).trim();
if (key) {
translations[key] = val;
}
}
}
try {
const updated = await updateLanguage(editingLang.code, {
name: values.name,
translations,
});
setLanguages((prev) =>
prev.map((lang) =>
lang.code === editingLang.code ? updated : lang,
),
);
message.success('语言更新成功');
closeEdit();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '更新失败';
message.error(errorMsg);
}
};
// --- Columns ---
const columns = [
{
title: '语言代码',
dataIndex: 'code',
key: 'code',
width: 160,
},
{
title: '语言名称',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: '状态',
dataIndex: 'enabled',
key: 'enabled',
width: 120,
render: (enabled: boolean, record: LanguageInfo) => (
<Switch checked={enabled} onChange={(checked) => handleToggle(record, checked)} />
),
},
{
title: '翻译条目数',
key: 'translationCount',
width: 140,
render: (_: unknown, record: LanguageInfo) =>
record.translations ? Object.keys(record.translations).length : 0,
},
{
title: '操作',
key: 'actions',
render: (_: unknown, record: LanguageInfo) => (
<Space>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
>
</Button>
</Space>
),
},
];
return (
<div>
<Typography.Title level={5} style={{ marginBottom: 16 }}>
</Typography.Title>
<Card>
<Table
columns={columns}
dataSource={languages}
rowKey="code"
loading={loading}
pagination={false}
/>
</Card>
{/* Edit Modal */}
<Modal
title={`编辑语言 - ${editingLang?.name ?? ''}`}
open={editModalOpen}
onCancel={closeEdit}
onOk={() => editForm.submit()}
>
<Form form={editForm} onFinish={handleEditSubmit} layout="vertical">
<Form.Item
name="name"
label="语言名称"
rules={[{ required: true, message: '请输入语言名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="translations"
label="翻译内容"
extra="每行一条格式key=value"
>
<Input.TextArea rows={10} placeholder={'common.save=保存\ncommon.cancel=取消'} />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,298 @@
import { useEffect, useState, 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 {
getMenus,
createMenu,
updateMenu,
deleteMenu,
type MenuInfo,
type MenuItemReq,
} from '../../api/menus';
// --- Types ---
type MenuItem = MenuInfo;
// --- Helpers ---
/** Convert nested menu tree back to flat list */
function flattenMenuTree(tree: MenuItem[]): MenuItem[] {
const result: MenuItem[] = [];
const walk = (items: MenuItem[]) => {
for (const item of items) {
const { children, ...rest } = item;
result.push(rest as MenuItem);
if (children?.length) walk(children);
}
};
walk(tree);
return result;
}
/** 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 tree = await getMenus();
setMenus(flattenMenuTree(tree));
setMenuTree(tree);
} catch {
message.error('加载菜单失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchMenus();
}, [fetchMenus]);
const handleSubmit = async (values: MenuItemReq) => {
try {
if (editMenu) {
await updateMenu(editMenu.id, values);
message.success('菜单更新成功');
} else {
await createMenu(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 deleteMenu(id);
message.success('菜单已删除');
fetchMenus();
} catch {
message.error('删除失败');
}
};
const openCreate = () => {
setEditMenu(null);
form.resetFields();
form.setFieldsValue({
menu_type: 'menu',
sort_order: 0,
visible: true,
});
setModalOpen(true);
};
const openEdit = (menu: MenuItem) => {
setEditMenu(menu);
form.setFieldsValue({
parent_id: menu.parent_id || undefined,
title: menu.title,
path: menu.path,
icon: menu.icon,
menu_type: menu.menu_type,
sort_order: menu.sort_order,
visible: menu.visible,
permission: menu.permission,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditMenu(null);
form.resetFields();
};
const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', width: 200 },
{
title: '路径',
dataIndex: 'path',
key: 'path',
ellipsis: true,
render: (v?: string) => v || '-',
},
{
title: '图标',
dataIndex: 'icon',
key: 'icon',
width: 100,
render: (v?: string) => v || '-',
},
{
title: '类型',
dataIndex: 'menu_type',
key: 'menu_type',
width: 90,
render: (v: string) => {
const info = menuTypeLabels[v] ?? { text: v, color: 'default' };
return <Tag color={info.color}>{info.text}</Tag>;
},
},
{
title: '排序',
dataIndex: 'sort_order',
key: 'sort_order',
width: 80,
},
{
title: '可见',
dataIndex: 'visible',
key: 'visible',
width: 80,
render: (v: boolean) =>
v ? <Tag color="green"></Tag> : <Tag color="default"></Tag>,
},
{
title: '操作',
key: 'actions',
width: 150,
render: (_: unknown, record: MenuItem) => (
<Space>
<Button size="small" onClick={() => openEdit(record)}>
</Button>
<Popconfirm
title="确定删除此菜单?"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</div>
<Table
columns={columns}
dataSource={menuTree}
rowKey="id"
loading={loading}
pagination={false}
indentSize={20}
/>
<Modal
title={editMenu ? '编辑菜单' : '添加菜单'}
open={modalOpen}
onCancel={closeModal}
onOk={() => form.submit()}
width={560}
>
<Form form={form} onFinish={handleSubmit} layout="vertical">
<Form.Item name="parent_id" label="上级菜单">
<TreeSelect
treeData={toTreeSelectData(menuTree)}
placeholder="无(顶级菜单)"
allowClear
treeDefaultExpandAll
/>
</Form.Item>
<Form.Item
name="title"
label="标题"
rules={[{ required: true, message: '请输入菜单标题' }]}
>
<Input />
</Form.Item>
<Form.Item name="path" label="路径">
<Input placeholder="/example/path" />
</Form.Item>
<Form.Item name="icon" label="图标">
<Input placeholder="图标名称,如 HomeOutlined" />
</Form.Item>
<Form.Item
name="menu_type"
label="类型"
rules={[{ required: true, message: '请选择菜单类型' }]}
>
<Select
options={[
{ label: '目录', value: 'directory' },
{ label: '菜单', value: 'menu' },
{ label: '按钮', value: 'button' },
]}
/>
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="visible" label="可见" valuePropName="checked" initialValue>
<Switch />
</Form.Item>
<Form.Item name="permission" label="权限标识">
<Input placeholder="如 system:user:list" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

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

View File

@@ -0,0 +1,249 @@
import { useState } from 'react';
import {
Button,
Form,
Input,
Space,
Popconfirm,
message,
Table,
Modal,
Tag,
theme,
} from 'antd';
import { PlusOutlined, SearchOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import {
getSetting,
updateSetting,
deleteSetting,
} from '../../api/settings';
interface SettingEntry {
key: string;
value: string;
}
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 { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const handleSearch = async () => {
if (!searchKey.trim()) {
message.warning('请输入设置键名');
return;
}
try {
const result = await getSetting(searchKey.trim());
const value = String(result.setting_value ?? '');
setEntries((prev) => {
const exists = prev.findIndex((e) => e.key === searchKey.trim());
if (exists >= 0) {
const updated = [...prev];
updated[exists] = { ...updated[exists], value };
return updated;
}
return [...prev, { key: searchKey.trim(), 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 {
try {
JSON.parse(value);
} catch {
message.error('设置值必须是有效的 JSON 格式');
return;
}
await updateSetting(key, 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 deleteSetting(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,
render: (v: string) => (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
border: 'none',
color: isDark ? '#CBD5E1' : '#475569',
fontFamily: 'monospace',
fontSize: 12,
}}>
{v}
</Tag>
),
},
{
title: '值 (JSON)',
dataIndex: 'value',
key: 'value',
ellipsis: true,
render: (v: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>
),
},
{
title: '操作',
key: 'actions',
width: 120,
render: (_: unknown, record: SettingEntry) => (
<Space size={4}>
<Button
size="small"
type="text"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }}
/>
<Popconfirm
title="确定删除此设置?"
onConfirm={() => handleDelete(record.key)}
>
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}>
<Space>
<Input
placeholder="输入设置键名查询"
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />}
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onPressEnter={handleSearch}
style={{ width: 300, borderRadius: 8 }}
/>
<Button onClick={handleSearch}></Button>
</Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button>
</div>
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
overflow: 'hidden',
}}>
<Table
columns={columns}
dataSource={entries}
rowKey="key"
size="small"
pagination={false}
locale={{ emptyText: '暂无设置项,请通过搜索查询或添加新设置' }}
/>
</div>
<Modal
title={editEntry ? '编辑设置' : '添加设置'}
open={modalOpen}
onCancel={closeModal}
onOk={() => form.submit()}
width={560}
>
<Form form={form} onFinish={handleSave} layout="vertical" style={{ marginTop: 16 }}>
<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"}' style={{ fontFamily: 'monospace' }} />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import { useEffect, useState, useCallback } from 'react';
import { Form, Input, Select, Button, ColorPicker, message, Typography } from 'antd';
import {
getTheme,
updateTheme,
} from '../../api/themes';
// --- Component ---
export default function ThemeSettings() {
const [form] = Form.useForm();
const [, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const fetchTheme = useCallback(async () => {
setLoading(true);
try {
const theme = await getTheme();
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 updateTheme({
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>
);
}

View File

@@ -0,0 +1,101 @@
import { useEffect, useCallback, useState } from 'react';
import { Table, Tag, theme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { listCompletedTasks, type TaskInfo } from '../../api/workflowTasks';
const outcomeStyles: Record<string, { bg: string; color: string; text: string }> = {
approved: { bg: '#ECFDF5', color: '#059669', text: '同意' },
rejected: { bg: '#FEF2F2', color: '#DC2626', text: '拒绝' },
delegated: { bg: '#EEF2FF', color: '#4F46E5', text: '已委派' },
};
export default function CompletedTasks() {
const [data, setData] = useState<TaskInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await listCompletedTasks(page, 20);
setData(res.data);
setTotal(res.total);
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => { fetchData(); }, [fetchData]);
const columns: ColumnsType<TaskInfo> = [
{
title: '任务名称',
dataIndex: 'node_name',
key: 'node_name',
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
},
{ title: '流程', dataIndex: 'definition_name', key: 'definition_name' },
{
title: '业务键',
dataIndex: 'business_key',
key: 'business_key',
render: (v: string | undefined) => v || '-',
},
{
title: '结果',
dataIndex: 'outcome',
key: 'outcome',
width: 100,
render: (o: string) => {
const info = outcomeStyles[o] || { bg: '#F1F5F9', color: '#64748B', text: o };
return (
<Tag style={{
background: info.bg,
border: 'none',
color: info.color,
fontWeight: 500,
}}>
{info.text}
</Tag>
);
},
},
{
title: '完成时间',
dataIndex: 'completed_at',
key: 'completed_at',
width: 180,
render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
{v ? new Date(v).toLocaleString() : '-'}
</span>
),
},
];
return (
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
overflow: 'hidden',
}}>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t} 条记录`,
}}
/>
</div>
);
}

View File

@@ -0,0 +1,247 @@
import { useEffect, useCallback, useState } from 'react';
import { Button, message, Modal, Table, Tag, theme } from 'antd';
import { EyeOutlined, PauseCircleOutlined, PlayCircleOutlined, StopOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {
listInstances,
resumeInstance,
suspendInstance,
terminateInstance,
type ProcessInstanceInfo,
} from '../../api/workflowInstances';
import { getProcessDefinition, type NodeDef, type EdgeDef } from '../../api/workflowDefinitions';
import ProcessViewer from './ProcessViewer';
const statusStyles: Record<string, { bg: string; color: string; text: string }> = {
running: { bg: '#EEF2FF', color: '#4F46E5', text: '运行中' },
suspended: { bg: '#FFFBEB', color: '#D97706', text: '已挂起' },
completed: { bg: '#ECFDF5', color: '#059669', text: '已完成' },
terminated: { bg: '#FEF2F2', color: '#DC2626', text: '已终止' },
};
export default function InstanceMonitor() {
const [data, setData] = useState<ProcessInstanceInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [viewerOpen, setViewerOpen] = useState(false);
const [viewerNodes, setViewerNodes] = useState<NodeDef[]>([]);
const [viewerEdges, setViewerEdges] = useState<EdgeDef[]>([]);
const [activeNodeIds, setActiveNodeIds] = useState<string[]>([]);
const [viewerLoading, setViewerLoading] = useState(false);
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await listInstances(page, 20);
setData(res.data);
setTotal(res.total);
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleViewFlow = async (record: ProcessInstanceInfo) => {
setViewerLoading(true);
setViewerOpen(true);
try {
const def = await getProcessDefinition(record.definition_id);
setViewerNodes(def.nodes);
setViewerEdges(def.edges);
setActiveNodeIds(record.active_tokens.map((t) => t.node_id));
} catch {
message.error('加载流程图失败');
setViewerOpen(false);
} finally {
setViewerLoading(false);
}
};
const handleTerminate = async (id: string) => {
Modal.confirm({
title: '确认终止',
content: '确定要终止该流程实例吗?此操作不可撤销。',
okText: '确定终止',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await terminateInstance(id);
message.success('已终止');
fetchData();
} catch {
message.error('操作失败');
}
},
});
};
const handleSuspend = async (id: string) => {
Modal.confirm({
title: '确认挂起',
content: '确定要挂起该流程实例吗?挂起后可通过"恢复"按钮继续执行。',
okText: '确定挂起',
okType: 'default',
cancelText: '取消',
onOk: async () => {
try {
await suspendInstance(id);
message.success('已挂起');
fetchData();
} catch {
message.error('操作失败');
}
},
});
};
const handleResume = async (id: string) => {
try {
await resumeInstance(id);
message.success('已恢复');
fetchData();
} catch {
message.error('操作失败');
}
};
const columns: ColumnsType<ProcessInstanceInfo> = [
{
title: '流程',
dataIndex: 'definition_name',
key: 'definition_name',
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
},
{
title: '业务键',
dataIndex: 'business_key',
key: 'business_key',
render: (v: string | undefined) => v || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (s: string) => {
const info = statusStyles[s] || { bg: '#F1F5F9', color: '#64748B', text: s };
return (
<Tag style={{
background: info.bg,
border: 'none',
color: info.color,
fontWeight: 500,
}}>
{info.text}
</Tag>
);
},
},
{
title: '当前节点',
key: 'current_nodes',
width: 150,
render: (_, record) => record.active_tokens.map(t => t.node_id).join(', ') || '-',
},
{
title: '发起时间',
dataIndex: 'started_at',
key: 'started_at',
width: 180,
render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
{new Date(v).toLocaleString()}
</span>
),
},
{
title: '操作',
key: 'action',
width: 240,
render: (_, record) => (
<div style={{ display: 'flex', gap: 4 }}>
<Button
size="small"
type="text"
icon={<EyeOutlined />}
onClick={() => handleViewFlow(record)}
>
</Button>
{record.status === 'running' && (
<>
<Button
size="small"
type="text"
icon={<PauseCircleOutlined />}
onClick={() => handleSuspend(record.id)}
>
</Button>
<Button
size="small"
type="text"
danger
icon={<StopOutlined />}
onClick={() => handleTerminate(record.id)}
>
</Button>
</>
)}
{record.status === 'suspended' && (
<Button
size="small"
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => handleResume(record.id)}
>
</Button>
)}
</div>
),
},
];
return (
<>
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
overflow: 'hidden',
}}>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t} 条记录`,
}}
/>
</div>
<Modal
title="流程图查看"
open={viewerOpen}
onCancel={() => setViewerOpen(false)}
footer={null}
width={720}
loading={viewerLoading}
>
<ProcessViewer nodes={viewerNodes} edges={viewerEdges} activeNodeIds={activeNodeIds} />
</Modal>
</>
);
}

View File

@@ -0,0 +1,217 @@
import { useEffect, useCallback, useState } from 'react';
import { Button, Input, message, Modal, Space, Table, Tag, theme } from 'antd';
import { CheckOutlined, CloseOutlined, SendOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {
listPendingTasks,
completeTask,
delegateTask,
type TaskInfo,
} from '../../api/workflowTasks';
export default function PendingTasks() {
const [data, setData] = useState<TaskInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [completeModal, setCompleteModal] = useState<TaskInfo | null>(null);
const [outcome, setOutcome] = useState('approved');
const [delegateModal, setDelegateModal] = useState<TaskInfo | null>(null);
const [delegateTo, setDelegateTo] = useState('');
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const fetchData = useCallback(async () => {
setLoading(true);
try {
const res = await listPendingTasks(page, 20);
setData(res.data);
setTotal(res.total);
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleComplete = async () => {
if (!completeModal) return;
try {
await completeTask(completeModal.id, { outcome });
message.success('审批完成');
setCompleteModal(null);
fetchData();
} catch {
message.error('审批失败');
}
};
const handleDelegate = async () => {
if (!delegateModal || !delegateTo.trim()) {
message.warning('请输入委派目标用户 ID');
return;
}
try {
await delegateTask(delegateModal.id, { delegate_to: delegateTo.trim() });
message.success('委派成功');
setDelegateModal(null);
setDelegateTo('');
fetchData();
} catch {
message.error('委派失败');
}
};
const columns: ColumnsType<TaskInfo> = [
{
title: '任务名称',
dataIndex: 'node_name',
key: 'node_name',
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
},
{ title: '流程', dataIndex: 'definition_name', key: 'definition_name' },
{
title: '业务键',
dataIndex: 'business_key',
key: 'business_key',
render: (v: string | undefined) => v ? (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
border: 'none',
color: isDark ? '#94A3B8' : '#64748B',
fontFamily: 'monospace',
fontSize: 12,
}}>
{v}
</Tag>
) : '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (s: string) => (
<Tag style={{
background: '#EEF2FF',
border: 'none',
color: '#4F46E5',
fontWeight: 500,
}}>
{s}
</Tag>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
{new Date(v).toLocaleString()}
</span>
),
},
{
title: '操作',
key: 'action',
width: 160,
render: (_, record) => (
<Space size={4}>
<Button
size="small"
type="primary"
icon={<CheckOutlined />}
onClick={() => { setCompleteModal(record); setOutcome('approved'); }}
>
</Button>
<Button
size="small"
type="text"
icon={<SendOutlined />}
onClick={() => { setDelegateModal(record); setDelegateTo(''); }}
>
</Button>
</Space>
),
},
];
return (
<>
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
overflow: 'hidden',
}}>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t} 条记录`,
}}
/>
</div>
<Modal
title="审批任务"
open={!!completeModal}
onOk={handleComplete}
onCancel={() => setCompleteModal(null)}
>
<div style={{ marginTop: 8 }}>
<p style={{ fontWeight: 500, marginBottom: 16 }}>
: {completeModal?.node_name}
</p>
<Space size={12}>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={() => setOutcome('approved')}
ghost={outcome !== 'approved'}
>
</Button>
<Button
danger
icon={<CloseOutlined />}
onClick={() => setOutcome('rejected')}
ghost={outcome !== 'rejected'}
>
</Button>
</Space>
</div>
</Modal>
<Modal
title="委派任务"
open={!!delegateModal}
onOk={handleDelegate}
onCancel={() => { setDelegateModal(null); setDelegateTo(''); }}
okText="确认委派"
>
<div style={{ marginTop: 8 }}>
<p style={{ fontWeight: 500, marginBottom: 16 }}>
: {delegateModal?.node_name}
</p>
<Input
placeholder="输入目标用户 ID (UUID)"
value={delegateTo}
onChange={(e) => setDelegateTo(e.target.value)}
/>
</div>
</Modal>
</>
);
}

View File

@@ -0,0 +1,199 @@
import { useEffect, useState, useCallback } from 'react';
import { Button, message, Modal, Space, Table, Tag, theme } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {
listProcessDefinitions,
createProcessDefinition,
updateProcessDefinition,
publishProcessDefinition,
type ProcessDefinitionInfo,
type CreateProcessDefinitionRequest,
} from '../../api/workflowDefinitions';
import ProcessDesigner from './ProcessDesigner';
const statusColors: Record<string, { bg: string; color: string; text: string }> = {
draft: { bg: '#F1F5F9', color: '#64748B', text: '草稿' },
published: { bg: '#ECFDF5', color: '#059669', text: '已发布' },
deprecated: { bg: '#FEF2F2', color: '#DC2626', text: '已弃用' },
};
export default function ProcessDefinitions() {
const [data, setData] = useState<ProcessDefinitionInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [designerOpen, setDesignerOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const fetchData = useCallback(async (p = page) => {
setLoading(true);
try {
const res = await listProcessDefinitions(p, 20);
setData(res.data);
setTotal(res.total);
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
fetchData();
}, [fetchData]);
const handleCreate = () => {
setEditingId(null);
setDesignerOpen(true);
};
const handleEdit = (id: string) => {
setEditingId(id);
setDesignerOpen(true);
};
const handlePublish = async (id: string) => {
try {
await publishProcessDefinition(id);
message.success('发布成功');
fetchData();
} catch {
message.error('发布失败');
}
};
const handleSave = async (req: CreateProcessDefinitionRequest, id?: string) => {
try {
if (id) {
await updateProcessDefinition(id, req);
message.success('更新成功');
} else {
await createProcessDefinition(req);
message.success('创建成功');
}
setDesignerOpen(false);
fetchData();
} catch {
message.error(id ? '更新失败' : '创建失败');
}
};
const columns: ColumnsType<ProcessDefinitionInfo> = [
{
title: '名称',
dataIndex: 'name',
key: 'name',
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
},
{
title: '编码',
dataIndex: 'key',
key: 'key',
render: (v: string) => (
<Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9',
border: 'none',
color: isDark ? '#94A3B8' : '#64748B',
fontFamily: 'monospace',
fontSize: 12,
}}>
{v}
</Tag>
),
},
{ title: '版本', dataIndex: 'version', key: 'version', width: 80 },
{ title: '分类', dataIndex: 'category', key: 'category', width: 120 },
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (s: string) => {
const info = statusColors[s] || { bg: '#F1F5F9', color: '#64748B', text: s };
return (
<Tag style={{
background: info.bg,
border: 'none',
color: info.color,
fontWeight: 500,
}}>
{info.text}
</Tag>
);
},
},
{
title: '操作',
key: 'action',
width: 200,
render: (_, record) => (
<Space size={4}>
{record.status === 'draft' && (
<>
<Button size="small" type="text" onClick={() => handleEdit(record.id)}>
</Button>
<Button size="small" type="primary" onClick={() => handlePublish(record.id)}>
</Button>
</>
)}
</Space>
),
},
];
return (
<>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}>
{total}
</span>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</div>
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
overflow: 'hidden',
}}>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t} 条记录`,
}}
/>
</div>
<Modal
title={editingId ? '编辑流程' : '新建流程'}
open={designerOpen}
onCancel={() => setDesignerOpen(false)}
footer={null}
width={1200}
destroyOnHidden
>
<ProcessDesigner
definitionId={editingId}
onSave={handleSave}
/>
</Modal>
</>
);
}

View File

@@ -0,0 +1,272 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Form, Input, message, Spin } from 'antd';
import {
ReactFlow,
Controls,
Background,
addEdge,
useNodesState,
useEdgesState,
type Connection,
type Node,
type Edge,
BackgroundVariant,
MarkerType,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {
type CreateProcessDefinitionRequest,
type NodeDef,
type EdgeDef,
getProcessDefinition,
} from '../../api/workflowDefinitions';
const NODE_TYPES_MAP: Record<string, { label: string; color: string }> = {
StartEvent: { label: '开始', color: '#52c41a' },
EndEvent: { label: '结束', color: '#ff4d4f' },
UserTask: { label: '用户任务', color: '#1890ff' },
ServiceTask: { label: '服务任务', color: '#722ed1' },
ExclusiveGateway: { label: '排他网关', color: '#fa8c16' },
ParallelGateway: { label: '并行网关', color: '#13c2c2' },
};
const PALETTE_ITEMS = Object.entries(NODE_TYPES_MAP).map(([type, info]) => ({
type,
label: info.label,
color: info.color,
}));
function createFlowNode(type: string, label: string, position: { x: number; y: number }, id?: string): Node {
return {
id: id || `node_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
type: 'default',
position,
data: { label: `${label}`, nodeType: type, name: label },
style: {
background: NODE_TYPES_MAP[type]?.color || '#f0f0f0',
color: '#fff',
padding: '8px 16px',
borderRadius: type.includes('Gateway') ? 0 : type === 'StartEvent' || type === 'EndEvent' ? 50 : 6,
fontSize: 13,
fontWeight: 500,
border: '2px solid rgba(255,255,255,0.3)',
width: type.includes('Gateway') ? 80 : 140,
textAlign: 'center' as const,
},
};
}
interface ProcessDesignerProps {
definitionId: string | null;
onSave: (req: CreateProcessDefinitionRequest, id?: string) => void;
}
export default function ProcessDesigner({ definitionId, onSave }: ProcessDesignerProps) {
const [form] = Form.useForm();
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const [loading, setLoading] = useState(false);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const isEditing = definitionId !== null;
// 加载流程定义(编辑模式)或初始化默认节点(新建模式)
useEffect(() => {
if (!definitionId) {
const startNode = createFlowNode('StartEvent', '开始', { x: 250, y: 50 });
const userNode = createFlowNode('UserTask', '审批', { x: 250, y: 200 });
const endNode = createFlowNode('EndEvent', '结束', { x: 250, y: 400 });
setNodes([startNode, userNode, endNode]);
setEdges([
{ id: 'e_start_approve', source: startNode.id, target: userNode.id, markerEnd: { type: MarkerType.ArrowClosed } },
{ id: 'e_approve_end', source: userNode.id, target: endNode.id, markerEnd: { type: MarkerType.ArrowClosed } },
]);
return;
}
setLoading(true);
getProcessDefinition(definitionId)
.then((def) => {
form.setFieldsValue({
name: def.name,
key: def.key,
category: def.category,
description: def.description,
});
const flowNodes = def.nodes.map((n, i) =>
createFlowNode(n.type, n.name, n.position || { x: 200, y: i * 120 + 50 }, n.id)
);
const flowEdges: Edge[] = def.edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
markerEnd: { type: MarkerType.ArrowClosed },
label: e.label || e.condition,
}));
setNodes(flowNodes);
setEdges(flowEdges);
})
.catch(() => message.error('加载流程定义失败'))
.finally(() => setLoading(false));
}, [definitionId]); // eslint-disable-line react-hooks/exhaustive-deps
const onConnect = useCallback(
(connection: Connection) => {
setEdges((eds) =>
addEdge(
{ ...connection, markerEnd: { type: MarkerType.ArrowClosed } },
eds,
),
);
},
[setEdges],
);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNode(node);
}, []);
const handleAddNode = (type: string) => {
const info = NODE_TYPES_MAP[type];
if (!info) return;
const newNode = createFlowNode(type, info.label, {
x: 100 + Math.random() * 400,
y: 100 + Math.random() * 300,
});
setNodes((nds) => [...nds, newNode]);
};
const handleDeleteNode = () => {
if (!selectedNode) return;
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
setEdges((eds) =>
eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id),
);
setSelectedNode(null);
};
const handleUpdateNodeName = (name: string) => {
if (!selectedNode) return;
setNodes((nds) =>
nds.map((n) =>
n.id === selectedNode.id
? { ...n, data: { ...n.data, label: name, name } }
: n,
),
);
setSelectedNode((prev) => (prev ? { ...prev, data: { ...prev.data, label: name, name } } : null));
};
const handleSave = () => {
form.validateFields().then((values) => {
const flowNodes: NodeDef[] = nodes.map((n) => ({
id: n.id,
type: (n.data.nodeType as NodeDef['type']) || 'UserTask',
name: String(n.data.name || n.data.label || ''),
position: { x: Math.round(n.position.x), y: Math.round(n.position.y) },
}));
const flowEdges: EdgeDef[] = edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
label: e.label ? String(e.label) : undefined,
}));
onSave(
{ ...values, nodes: flowNodes, edges: flowEdges },
definitionId || undefined,
);
}).catch(() => {
message.error('请填写必要字段');
});
};
const defaultEdgeOptions = useMemo(
() => ({
markerEnd: { type: MarkerType.ArrowClosed },
}),
[],
);
if (loading) {
return <div style={{ display: 'flex', justifyContent: 'center', padding: 100 }}><Spin /></div>;
}
return (
<div style={{ display: 'flex', gap: 16, height: 500 }}>
{/* 左侧工具面板 */}
<div style={{ width: 180, display: 'flex', flexDirection: 'column', gap: 8 }}>
<p style={{ fontWeight: 500, margin: '0 0 4px' }}></p>
{PALETTE_ITEMS.map((item) => (
<Button
key={item.type}
size="small"
style={{ textAlign: 'left' }}
onClick={() => handleAddNode(item.type)}
>
<span style={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: 2,
background: item.color,
marginRight: 6,
}} />
{item.label}
</Button>
))}
{selectedNode && (
<div style={{ marginTop: 16, padding: 8, background: '#f5f5f5', borderRadius: 6 }}>
<p style={{ fontWeight: 500, margin: '0 0 8px', fontSize: 12 }}></p>
<Input
size="small"
value={String(selectedNode.data.name || '')}
onChange={(e) => handleUpdateNodeName(e.target.value)}
placeholder="节点名称"
style={{ marginBottom: 8 }}
/>
<Button size="small" danger onClick={handleDeleteNode} block>
</Button>
</div>
)}
</div>
{/* 中间画布 */}
<div style={{ flex: 1, border: '1px solid #d9d9d9', borderRadius: 6 }}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
defaultEdgeOptions={defaultEdgeOptions}
fitView
>
<Controls />
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
</ReactFlow>
</div>
{/* 右侧表单 */}
<div style={{ width: 220, overflow: 'auto' }}>
<Form form={form} layout="vertical" size="small">
<Form.Item name="name" label="流程名称" rules={[{ required: true, message: '请输入' }]}>
<Input placeholder="请假审批" />
</Form.Item>
<Form.Item name="key" label="流程编码" rules={[{ required: true, message: '请输入' }]}>
<Input placeholder="leave_approval" disabled={isEditing} />
</Form.Item>
<Form.Item name="category" label="分类">
<Input placeholder="leave" />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={2} />
</Form.Item>
<Button type="primary" onClick={handleSave}>{isEditing ? '更新' : '保存'}</Button>
</Form>
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
import { useMemo } from 'react';
import {
ReactFlow,
Controls,
Background,
BackgroundVariant,
MarkerType,
type Node,
type Edge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type { NodeDef, EdgeDef } from '../../api/workflowDefinitions';
const NODE_TYPE_STYLES: Record<string, { color: string; radius: number; width: number }> = {
StartEvent: { color: '#52c41a', radius: 50, width: 100 },
EndEvent: { color: '#ff4d4f', radius: 50, width: 100 },
UserTask: { color: '#1890ff', radius: 6, width: 160 },
ServiceTask: { color: '#722ed1', radius: 6, width: 160 },
ExclusiveGateway: { color: '#fa8c16', radius: 0, width: 100 },
ParallelGateway: { color: '#13c2c2', radius: 0, width: 100 },
};
interface ProcessViewerProps {
nodes: NodeDef[];
edges: EdgeDef[];
activeNodeIds?: string[];
}
export default function ProcessViewer({ nodes, edges, activeNodeIds = [] }: ProcessViewerProps) {
const flowNodes: Node[] = useMemo(() =>
nodes.map((n, i) => {
const style = NODE_TYPE_STYLES[n.type] || NODE_TYPE_STYLES.UserTask;
const isActive = activeNodeIds.includes(n.id);
return {
id: n.id,
type: 'default',
position: n.position || { x: 200, y: i * 120 + 50 },
data: { label: n.name },
style: {
background: isActive ? '#fff3cd' : style.color,
color: isActive ? '#856404' : '#fff',
padding: '8px 16px',
borderRadius: style.radius,
fontSize: 13,
fontWeight: 500,
border: isActive ? '3px solid #ffc107' : '2px solid rgba(255,255,255,0.3)',
width: style.width,
textAlign: 'center' as const,
boxShadow: isActive ? '0 0 8px rgba(255,193,7,0.5)' : 'none',
},
};
}),
[nodes, activeNodeIds],
);
const flowEdges: Edge[] = useMemo(() =>
edges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
label: e.label || e.condition,
markerEnd: { type: MarkerType.ArrowClosed },
style: { stroke: '#999' },
})),
[edges],
);
return (
<div style={{ height: 400, border: '1px solid #d9d9d9', borderRadius: 6 }}>
<ReactFlow
nodes={flowNodes}
edges={flowEdges}
fitView
proOptions={{ hideAttribution: true }}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
>
<Controls showInteractive={false} />
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
</ReactFlow>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand';
interface AppState {
theme: 'light' | 'dark';
sidebarCollapsed: boolean;
toggleSidebar: () => void;
setTheme: (theme: 'light' | 'dark') => void;
}
export const useAppStore = create<AppState>((set) => ({
theme: 'light',
sidebarCollapsed: false,
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setTheme: (theme) => set({ theme }),
}));

View File

@@ -0,0 +1,69 @@
import { create } from 'zustand';
import { login as apiLogin, logout as apiLogout, type UserInfo } from '../api/auth';
// Synchronously restore auth state from localStorage at store creation time.
// This eliminates the flash-of-login-page on refresh because isAuthenticated
// is already `true` before the first render.
function restoreInitialState(): { user: UserInfo | null; isAuthenticated: boolean } {
const token = localStorage.getItem('access_token');
const userStr = localStorage.getItem('user');
if (token && userStr) {
try {
const user = JSON.parse(userStr) as UserInfo;
return { user, isAuthenticated: true };
} catch {
localStorage.removeItem('user');
}
}
return { user: null, isAuthenticated: false };
}
const initial = restoreInitialState();
interface AuthState {
user: UserInfo | null;
isAuthenticated: boolean;
loading: boolean;
login: (username: string, password: string) => Promise<void>;
logout: () => Promise<void>;
loadFromStorage: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: initial.user,
isAuthenticated: initial.isAuthenticated,
loading: false,
login: async (username, password) => {
set({ loading: true });
try {
const resp = await apiLogin({ username, password });
localStorage.setItem('access_token', resp.access_token);
localStorage.setItem('refresh_token', resp.refresh_token);
localStorage.setItem('user', JSON.stringify(resp.user));
set({ user: resp.user, isAuthenticated: true, loading: false });
} catch (error) {
set({ loading: false });
throw error;
}
},
logout: async () => {
try {
await apiLogout();
} catch {
// Ignore logout API errors
}
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user');
set({ user: null, isAuthenticated: false });
},
// Kept for backward compatibility but no longer needed since
// initial state is restored synchronously at store creation.
loadFromStorage: () => {
const state = restoreInitialState();
set({ user: state.user, isAuthenticated: state.isAuthenticated });
},
}));

View File

@@ -0,0 +1,70 @@
import { create } from 'zustand';
import { getUnreadCount, listMessages, markRead, type MessageInfo } from '../api/messages';
interface MessageState {
unreadCount: number;
recentMessages: MessageInfo[];
fetchUnreadCount: () => Promise<void>;
fetchRecentMessages: () => Promise<void>;
markAsRead: (id: string) => Promise<void>;
}
// 请求去重:记录正在进行的请求,防止并发重复调用
let unreadCountPromise: Promise<void> | null = null;
let recentMessagesPromise: Promise<void> | null = null;
export const useMessageStore = create<MessageState>((set) => ({
unreadCount: 0,
recentMessages: [],
fetchUnreadCount: async () => {
// 如果已有进行中的请求,复用该 Promise
if (unreadCountPromise) {
await unreadCountPromise;
return;
}
unreadCountPromise = (async () => {
try {
const result = await getUnreadCount();
set({ unreadCount: result.count });
} catch {
// 静默失败,不影响用户体验
} finally {
unreadCountPromise = null;
}
})();
await unreadCountPromise;
},
fetchRecentMessages: async () => {
if (recentMessagesPromise) {
await recentMessagesPromise;
return;
}
recentMessagesPromise = (async () => {
try {
const result = await listMessages({ page: 1, page_size: 5 });
set({ recentMessages: result.data });
} catch {
// 静默失败
} finally {
recentMessagesPromise = null;
}
})();
await recentMessagesPromise;
},
markAsRead: async (id: string) => {
try {
await markRead(id);
set((state) => ({
unreadCount: Math.max(0, state.unreadCount - 1),
recentMessages: state.recentMessages.map((m) =>
m.id === id ? { ...m, is_read: true } : m,
),
}));
} catch {
// 静默失败
}
},
}));

View File

@@ -0,0 +1,59 @@
import { create } from 'zustand';
import type { PluginInfo, PluginStatus } from '../api/plugins';
import { listPlugins } from '../api/plugins';
export interface PluginMenuItem {
key: string;
icon: string;
label: string;
pluginId: string;
entity: string;
menuGroup?: string;
}
interface PluginStore {
plugins: PluginInfo[];
loading: boolean;
pluginMenuItems: PluginMenuItem[];
fetchPlugins: (page?: number, status?: PluginStatus) => Promise<void>;
refreshMenuItems: () => void;
}
export const usePluginStore = create<PluginStore>((set, get) => ({
plugins: [],
loading: false,
pluginMenuItems: [],
fetchPlugins: async (page = 1, status?: PluginStatus) => {
set({ loading: true });
try {
const result = await listPlugins(page, 100, status);
set({ plugins: result.data });
get().refreshMenuItems();
} finally {
set({ loading: false });
}
},
refreshMenuItems: () => {
const { plugins } = get();
const items: PluginMenuItem[] = [];
for (const plugin of plugins) {
if (plugin.status !== 'running' && plugin.status !== 'enabled') continue;
for (const entity of plugin.entities) {
items.push({
key: `/plugins/${plugin.id}/${entity.name}`,
icon: 'AppstoreOutlined',
label: entity.display_name || entity.name,
pluginId: plugin.id,
entity: entity.name,
menuGroup: undefined,
});
}
}
set({ pluginMenuItems: items });
},
}));

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

53
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,53 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), ...tailwindcss()],
server: {
port: 5174,
proxy: {
"/api": {
target: "http://localhost:3000",
changeOrigin: true,
},
"/ws": {
target: "ws://localhost:3000",
ws: true,
},
},
},
build: {
target: "es2023",
cssTarget: "chrome120",
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes("node_modules/react-dom") || id.includes("node_modules/react/") || id.includes("node_modules/react-router-dom")) {
return "vendor-react";
}
if (id.includes("node_modules/antd") || id.includes("node_modules/@ant-design")) {
return "vendor-antd";
}
if (id.includes("node_modules/axios") || id.includes("node_modules/zustand")) {
return "vendor-utils";
}
},
},
},
sourcemap: false,
reportCompressedSize: false,
chunkSizeWarningLimit: 600,
},
optimizeDeps: {
include: [
"react",
"react-dom",
"react-router-dom",
"antd",
"@ant-design/icons",
"axios",
"zustand",
],
},
});

View File

@@ -14,3 +14,10 @@ axum.workspace = true
sea-orm.workspace = true
tracing.workspace = true
anyhow.workspace = true
thiserror.workspace = true
jsonwebtoken.workspace = true
argon2.workspace = true
sha2.workspace = true
validator.workspace = true
utoipa.workspace = true
async-trait.workspace = true

View File

@@ -0,0 +1,77 @@
use erp_core::events::EventBus;
use sea_orm::DatabaseConnection;
use uuid::Uuid;
/// Auth-specific state extracted from the server's AppState via `FromRef`.
///
/// This avoids a circular dependency between erp-auth and erp-server.
/// The server crate implements `FromRef<AppState> for AuthState` so that
/// Axum handlers in erp-auth can extract `State<AuthState>` directly.
///
/// Contains everything the auth handlers need:
/// - Database connection for user/credential lookups
/// - EventBus for publishing domain events
/// - JWT configuration for token signing and validation
/// - Default tenant ID for the bootstrap phase
#[derive(Clone)]
pub struct AuthState {
pub db: DatabaseConnection,
pub event_bus: EventBus,
pub jwt_secret: String,
pub access_ttl_secs: i64,
pub refresh_ttl_secs: i64,
pub default_tenant_id: Uuid,
}
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.
///
/// Falls back to parsing the raw string as seconds if no unit suffix is recognized.
pub fn parse_ttl(ttl: &str) -> i64 {
let ttl = ttl.trim();
if let Some(num) = ttl.strip_suffix('s') {
num.parse::<i64>().unwrap_or(900)
} else if let Some(num) = ttl.strip_suffix('m') {
num.parse::<i64>().map(|n| n * 60).unwrap_or(900)
} else if let Some(num) = ttl.strip_suffix('h') {
num.parse::<i64>().map(|n| n * 3600).unwrap_or(900)
} else if let Some(num) = ttl.strip_suffix('d') {
num.parse::<i64>().map(|n| n * 86400).unwrap_or(900)
} else {
ttl.parse::<i64>().unwrap_or(900)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ttl_seconds() {
assert_eq!(parse_ttl("900s"), 900);
}
#[test]
fn parse_ttl_minutes() {
assert_eq!(parse_ttl("15m"), 900);
}
#[test]
fn parse_ttl_hours() {
assert_eq!(parse_ttl("1h"), 3600);
}
#[test]
fn parse_ttl_days() {
assert_eq!(parse_ttl("7d"), 604800);
}
#[test]
fn parse_ttl_raw_number() {
assert_eq!(parse_ttl("300"), 300);
}
#[test]
fn parse_ttl_fallback_on_invalid() {
assert_eq!(parse_ttl("invalid"), 900);
}
}

436
crates/erp-auth/src/dto.rs Normal file
View File

@@ -0,0 +1,436 @@
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use validator::Validate;
// --- Auth DTOs ---
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct LoginReq {
#[validate(length(min = 1, message = "用户名不能为空"))]
pub username: String,
#[validate(length(min = 1, message = "密码不能为空"))]
pub password: String,
}
#[derive(Debug, Serialize, ToSchema)]
pub struct LoginResp {
pub access_token: String,
pub refresh_token: String,
pub expires_in: u64,
pub user: UserResp,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct RefreshReq {
pub refresh_token: String,
}
/// 修改密码请求
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct ChangePasswordReq {
#[validate(length(min = 1, message = "当前密码不能为空"))]
pub current_password: String,
#[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))]
pub new_password: String,
}
// --- User DTOs ---
#[derive(Debug, Serialize, ToSchema)]
pub struct UserResp {
pub id: Uuid,
pub username: String,
pub email: Option<String>,
pub phone: Option<String>,
pub display_name: Option<String>,
pub avatar_url: Option<String>,
pub status: String,
pub roles: Vec<RoleResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateUserReq {
#[validate(length(min = 1, max = 50))]
pub username: String,
#[validate(length(min = 6, max = 128))]
pub password: String,
#[validate(email)]
pub email: Option<String>,
pub phone: Option<String>,
pub display_name: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateUserReq {
pub email: Option<String>,
pub phone: Option<String>,
pub display_name: Option<String>,
pub status: Option<String>,
pub version: i32,
}
// --- Role DTOs ---
#[derive(Debug, Serialize, ToSchema)]
pub struct RoleResp {
pub id: Uuid,
pub name: String,
pub code: String,
pub description: Option<String>,
pub is_system: bool,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateRoleReq {
#[validate(length(min = 1, max = 50))]
pub name: String,
#[validate(length(min = 1, max = 50))]
pub code: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateRoleReq {
pub name: Option<String>,
pub description: Option<String>,
pub version: i32,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct AssignRolesReq {
pub role_ids: Vec<Uuid>,
}
// --- Permission DTOs ---
#[derive(Debug, Serialize, ToSchema)]
pub struct PermissionResp {
pub id: Uuid,
pub code: String,
pub name: String,
pub resource: String,
pub action: String,
pub description: Option<String>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct AssignPermissionsReq {
pub permission_ids: Vec<Uuid>,
}
// --- Organization DTOs ---
#[derive(Debug, Serialize, ToSchema)]
pub struct OrganizationResp {
pub id: Uuid,
pub name: String,
pub code: Option<String>,
pub parent_id: Option<Uuid>,
pub path: Option<String>,
pub level: i32,
pub sort_order: i32,
pub children: Vec<OrganizationResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateOrganizationReq {
#[validate(length(min = 1))]
pub name: String,
pub code: Option<String>,
pub parent_id: Option<Uuid>,
pub sort_order: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateOrganizationReq {
pub name: Option<String>,
pub code: Option<String>,
pub sort_order: Option<i32>,
pub version: i32,
}
// --- Department DTOs ---
#[derive(Debug, Serialize, ToSchema)]
pub struct DepartmentResp {
pub id: Uuid,
pub org_id: Uuid,
pub name: String,
pub code: Option<String>,
pub parent_id: Option<Uuid>,
pub manager_id: Option<Uuid>,
pub path: Option<String>,
pub sort_order: i32,
pub children: Vec<DepartmentResp>,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreateDepartmentReq {
#[validate(length(min = 1))]
pub name: String,
pub code: Option<String>,
pub parent_id: Option<Uuid>,
pub manager_id: Option<Uuid>,
pub sort_order: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdateDepartmentReq {
pub name: Option<String>,
pub code: Option<String>,
pub manager_id: Option<Uuid>,
pub sort_order: Option<i32>,
pub version: i32,
}
// --- Position DTOs ---
#[derive(Debug, Serialize, ToSchema)]
pub struct PositionResp {
pub id: Uuid,
pub dept_id: Uuid,
pub name: String,
pub code: Option<String>,
pub level: i32,
pub sort_order: i32,
pub version: i32,
}
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct CreatePositionReq {
#[validate(length(min = 1))]
pub name: String,
pub code: Option<String>,
pub level: Option<i32>,
pub sort_order: Option<i32>,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct UpdatePositionReq {
pub name: Option<String>,
pub code: Option<String>,
pub level: Option<i32>,
pub sort_order: Option<i32>,
pub version: i32,
}
#[cfg(test)]
mod tests {
use super::*;
use validator::Validate;
#[test]
fn login_req_valid() {
let req = LoginReq {
username: "admin".to_string(),
password: "password123".to_string(),
};
assert!(req.validate().is_ok());
}
#[test]
fn login_req_empty_username_fails() {
let req = LoginReq {
username: "".to_string(),
password: "password123".to_string(),
};
let result = req.validate();
assert!(result.is_err());
}
#[test]
fn change_password_req_valid() {
let req = ChangePasswordReq {
current_password: "oldPassword123".to_string(),
new_password: "newPassword456".to_string(),
};
assert!(req.validate().is_ok());
}
#[test]
fn change_password_req_empty_current_fails() {
let req = ChangePasswordReq {
current_password: "".to_string(),
new_password: "newPassword456".to_string(),
};
assert!(req.validate().is_err());
}
#[test]
fn change_password_req_short_new_fails() {
let req = ChangePasswordReq {
current_password: "oldPassword123".to_string(),
new_password: "12345".to_string(), // min 6
};
assert!(req.validate().is_err());
}
#[test]
fn change_password_req_long_new_fails() {
let req = ChangePasswordReq {
current_password: "oldPassword123".to_string(),
new_password: "a".repeat(129), // max 128
};
assert!(req.validate().is_err());
}
#[test]
fn login_req_empty_password_fails() {
let req = LoginReq {
username: "admin".to_string(),
password: "".to_string(),
};
assert!(req.validate().is_err());
}
#[test]
fn create_user_req_valid() {
let req = CreateUserReq {
username: "alice".to_string(),
password: "secret123".to_string(),
email: Some("alice@example.com".to_string()),
phone: None,
display_name: Some("Alice".to_string()),
};
assert!(req.validate().is_ok());
}
#[test]
fn create_user_req_short_password_fails() {
let req = CreateUserReq {
username: "bob".to_string(),
password: "12345".to_string(), // min 6
email: None,
phone: None,
display_name: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_user_req_empty_username_fails() {
let req = CreateUserReq {
username: "".to_string(),
password: "secret123".to_string(),
email: None,
phone: None,
display_name: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_user_req_invalid_email_fails() {
let req = CreateUserReq {
username: "charlie".to_string(),
password: "secret123".to_string(),
email: Some("not-an-email".to_string()),
phone: None,
display_name: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_user_req_long_username_fails() {
let req = CreateUserReq {
username: "a".repeat(51), // max 50
password: "secret123".to_string(),
email: None,
phone: None,
display_name: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_role_req_valid() {
let req = CreateRoleReq {
name: "管理员".to_string(),
code: "admin".to_string(),
description: Some("系统管理员".to_string()),
};
assert!(req.validate().is_ok());
}
#[test]
fn create_role_req_empty_name_fails() {
let req = CreateRoleReq {
name: "".to_string(),
code: "admin".to_string(),
description: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_role_req_empty_code_fails() {
let req = CreateRoleReq {
name: "管理员".to_string(),
code: "".to_string(),
description: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_org_req_valid() {
let req = CreateOrganizationReq {
name: "总部".to_string(),
code: Some("HQ".to_string()),
parent_id: None,
sort_order: Some(0),
};
assert!(req.validate().is_ok());
}
#[test]
fn create_org_req_empty_name_fails() {
let req = CreateOrganizationReq {
name: "".to_string(),
code: None,
parent_id: None,
sort_order: None,
};
assert!(req.validate().is_err());
}
#[test]
fn create_dept_req_valid() {
let req = CreateDepartmentReq {
name: "技术部".to_string(),
code: Some("TECH".to_string()),
parent_id: None,
manager_id: None,
sort_order: Some(1),
};
assert!(req.validate().is_ok());
}
#[test]
fn create_position_req_valid() {
let req = CreatePositionReq {
name: "高级工程师".to_string(),
code: Some("SENIOR".to_string()),
level: Some(3),
sort_order: None,
};
assert!(req.validate().is_ok());
}
#[test]
fn create_position_req_empty_name_fails() {
let req = CreatePositionReq {
name: "".to_string(),
code: None,
level: None,
sort_order: None,
};
assert!(req.validate().is_err());
}
}

View File

@@ -0,0 +1,68 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "departments")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub org_id: Uuid,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manager_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub sort_order: i32,
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::organization::Entity",
from = "Column::OrgId",
to = "super::organization::Column::Id",
on_delete = "Restrict"
)]
Organization,
#[sea_orm(has_many = "super::position::Entity")]
Position,
#[sea_orm(
belongs_to = "super::user::Entity",
from = "Column::ManagerId",
to = "super::user::Column::Id",
on_delete = "SetNull"
)]
Manager,
}
impl Related<super::organization::Entity> for Entity {
fn to() -> RelationDef {
Relation::Organization.def()
}
}
impl Related<super::position::Entity> for Entity {
fn to() -> RelationDef {
Relation::Position.def()
}
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::Manager.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,10 @@
pub mod department;
pub mod organization;
pub mod permission;
pub mod position;
pub mod role;
pub mod role_permission;
pub mod user;
pub mod user_credential;
pub mod user_role;
pub mod user_token;

View File

@@ -0,0 +1,40 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "organizations")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub level: i32,
pub sort_order: i32,
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::department::Entity")]
Department,
}
impl Related<super::department::Entity> for Entity {
fn to() -> RelationDef {
Relation::Department.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,37 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "permissions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub code: String,
pub name: String,
pub resource: String,
pub action: 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::role_permission::Entity")]
RolePermission,
}
impl Related<super::role_permission::Entity> for Entity {
fn to() -> RelationDef {
Relation::RolePermission.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View 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 = "positions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub dept_id: Uuid,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<String>,
pub level: i32,
pub sort_order: i32,
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::department::Entity",
from = "Column::DeptId",
to = "super::department::Column::Id",
on_delete = "Restrict"
)]
Department,
}
impl Related<super::department::Entity> for Entity {
fn to() -> RelationDef {
Relation::Department.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,44 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "roles")]
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 is_system: bool,
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::role_permission::Entity")]
RolePermission,
#[sea_orm(has_many = "super::user_role::Entity")]
UserRole,
}
impl Related<super::role_permission::Entity> for Entity {
fn to() -> RelationDef {
Relation::RolePermission.def()
}
}
impl Related<super::user_role::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserRole.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,51 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "role_permissions")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub role_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
pub permission_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::role::Entity",
from = "Column::RoleId",
to = "super::role::Column::Id",
on_delete = "Cascade"
)]
Role,
#[sea_orm(
belongs_to = "super::permission::Entity",
from = "Column::PermissionId",
to = "super::permission::Column::Id",
on_delete = "Cascade"
)]
Permission,
}
impl Related<super::role::Entity> for Entity {
fn to() -> RelationDef {
Relation::Role.def()
}
}
impl Related<super::permission::Entity> for Entity {
fn to() -> RelationDef {
Relation::Permission.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,59 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "users")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub username: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_login_at: Option<DateTimeUtc>,
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::user_credential::Entity")]
UserCredential,
#[sea_orm(has_many = "super::user_token::Entity")]
UserToken,
#[sea_orm(has_many = "super::user_role::Entity")]
UserRole,
}
impl Related<super::user_credential::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserCredential.def()
}
}
impl Related<super::user_token::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserToken.def()
}
}
impl Related<super::user_role::Entity> for Entity {
fn to() -> RelationDef {
Relation::UserRole.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,41 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user_credentials")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub credential_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub credential_data: Option<serde_json::Value>,
pub verified: bool,
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::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
on_delete = "Cascade"
)]
User,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,51 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user_roles")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub user_id: Uuid,
#[sea_orm(primary_key, auto_increment = false)]
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::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
on_delete = "Cascade"
)]
User,
#[sea_orm(
belongs_to = "super::role::Entity",
from = "Column::RoleId",
to = "super::role::Column::Id",
on_delete = "Cascade"
)]
Role,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl Related<super::role::Entity> for Entity {
fn to() -> RelationDef {
Relation::Role.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,44 @@
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
#[sea_orm(table_name = "user_tokens")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: Uuid,
pub tenant_id: Uuid,
pub user_id: Uuid,
pub token_hash: String,
pub token_type: String,
pub expires_at: DateTimeUtc,
#[serde(skip_serializing_if = "Option::is_none")]
pub revoked_at: Option<DateTimeUtc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub device_info: 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::user::Entity",
from = "Column::UserId",
to = "super::user::Column::Id",
on_delete = "Cascade"
)]
User,
}
impl Related<super::user::Entity> for Entity {
fn to() -> RelationDef {
Relation::User.def()
}
}
impl ActiveModelBehavior for ActiveModel {}

View File

@@ -0,0 +1,131 @@
use erp_core::error::AppError;
/// Auth module error types
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
#[error("用户名或密码错误")]
InvalidCredentials,
#[error("Token 已过期")]
TokenExpired,
#[error("Token 已被吊销")]
TokenRevoked,
#[error("用户已被{0}")]
UserDisabled(String),
#[error("密码哈希错误")]
HashError(String),
#[error("JWT 错误: {0}")]
JwtError(#[from] jsonwebtoken::errors::Error),
#[error("{0}")]
Validation(String),
#[error("版本冲突: 数据已被其他操作修改,请刷新后重试")]
VersionMismatch,
}
impl From<AuthError> for AppError {
fn from(err: AuthError) -> Self {
match err {
AuthError::InvalidCredentials => AppError::Unauthorized,
AuthError::TokenExpired => AppError::Unauthorized,
AuthError::TokenRevoked => AppError::Unauthorized,
AuthError::UserDisabled(s) => AppError::Forbidden(s),
AuthError::Validation(s) => AppError::Validation(s),
AuthError::HashError(_) => AppError::Internal(err.to_string()),
AuthError::JwtError(_) => AppError::Unauthorized,
AuthError::VersionMismatch => AppError::VersionMismatch,
}
}
}
impl From<AppError> for AuthError {
fn from(err: AppError) -> Self {
match err {
AppError::VersionMismatch => AuthError::VersionMismatch,
other => AuthError::Validation(other.to_string()),
}
}
}
pub type AuthResult<T> = Result<T, AuthError>;
#[cfg(test)]
mod tests {
use super::*;
use erp_core::error::AppError;
#[test]
fn auth_error_invalid_credentials_maps_to_unauthorized() {
let app: AppError = AuthError::InvalidCredentials.into();
match app {
AppError::Unauthorized => {}
other => panic!("Expected Unauthorized, got {:?}", other),
}
}
#[test]
fn auth_error_token_expired_maps_to_unauthorized() {
let app: AppError = AuthError::TokenExpired.into();
match app {
AppError::Unauthorized => {}
other => panic!("Expected Unauthorized, got {:?}", other),
}
}
#[test]
fn auth_error_user_disabled_maps_to_forbidden() {
let app: AppError = AuthError::UserDisabled("已禁用".to_string()).into();
match app {
AppError::Forbidden(msg) => assert_eq!(msg, "已禁用"),
other => panic!("Expected Forbidden, got {:?}", other),
}
}
#[test]
fn auth_error_hash_error_maps_to_internal() {
let app: AppError = AuthError::HashError("argon2 failed".to_string()).into();
match app {
AppError::Internal(_) => {}
other => panic!("Expected Internal, got {:?}", other),
}
}
#[test]
fn auth_error_validation_maps_to_validation() {
let app: AppError = AuthError::Validation("用户名已存在".to_string()).into();
match app {
AppError::Validation(msg) => assert_eq!(msg, "用户名已存在"),
other => panic!("Expected Validation, got {:?}", other),
}
}
#[test]
fn auth_error_version_mismatch_roundtrip() {
// AuthError::VersionMismatch -> AppError::VersionMismatch -> AuthError::VersionMismatch
let app: AppError = AuthError::VersionMismatch.into();
match app {
AppError::VersionMismatch => {}
other => panic!("Expected VersionMismatch, got {:?}", other),
}
// And back
let auth: AuthError = AppError::VersionMismatch.into();
match auth {
AuthError::VersionMismatch => {}
other => panic!("Expected VersionMismatch, got {:?}", other),
}
}
#[test]
fn app_error_other_maps_to_auth_validation() {
let auth: AuthError = AppError::NotFound("not found".to_string()).into();
match auth {
AuthError::Validation(msg) => assert!(msg.contains("not found")),
other => panic!("Expected Validation, got {:?}", other),
}
}
}

View File

@@ -0,0 +1,168 @@
use axum::Extension;
use axum::extract::{FromRef, State};
use axum::response::Json;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, TenantContext};
use crate::auth_state::AuthState;
use crate::dto::{ChangePasswordReq, LoginReq, LoginResp, RefreshReq};
use crate::service::auth_service::{AuthService, JwtConfig};
#[utoipa::path(
post,
path = "/api/v1/auth/login",
request_body = LoginReq,
responses(
(status = 200, description = "登录成功", body = ApiResponse<LoginResp>),
(status = 400, description = "请求参数错误"),
(status = 401, description = "用户名或密码错误"),
),
tag = "认证"
)]
/// POST /api/v1/auth/login
///
/// Authenticates a user with username and password, returning access and refresh tokens.
///
/// During the bootstrap phase, the tenant_id is taken from `AuthState::default_tenant_id`.
/// In production, this will come from a tenant-resolution middleware.
pub async fn login<S>(
State(state): State<AuthState>,
Json(req): Json<LoginReq>,
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let tenant_id = state.default_tenant_id;
let jwt_config = JwtConfig {
secret: &state.jwt_secret,
access_ttl_secs: state.access_ttl_secs,
refresh_ttl_secs: state.refresh_ttl_secs,
};
let resp = AuthService::login(
tenant_id,
&req.username,
&req.password,
&state.db,
&jwt_config,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
post,
path = "/api/v1/auth/refresh",
request_body = RefreshReq,
responses(
(status = 200, description = "刷新成功", body = ApiResponse<LoginResp>),
(status = 401, description = "刷新令牌无效或已过期"),
),
tag = "认证"
)]
/// POST /api/v1/auth/refresh
///
/// Validates an existing refresh token, revokes it (rotation), and issues
/// a new access + refresh token pair.
pub async fn refresh<S>(
State(state): State<AuthState>,
Json(req): Json<RefreshReq>,
) -> Result<Json<ApiResponse<LoginResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let jwt_config = JwtConfig {
secret: &state.jwt_secret,
access_ttl_secs: state.access_ttl_secs,
refresh_ttl_secs: state.refresh_ttl_secs,
};
let resp = AuthService::refresh(&req.refresh_token, &state.db, &jwt_config).await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
post,
path = "/api/v1/auth/logout",
responses(
(status = 200, description = "已成功登出"),
(status = 401, description = "未授权"),
),
security(("bearer_auth" = [])),
tag = "认证"
)]
/// POST /api/v1/auth/logout
///
/// Revokes all refresh tokens for the authenticated user, effectively
/// logging them out on all devices.
pub async fn logout<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
AuthService::logout(ctx.user_id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("已成功登出".to_string()),
}))
}
#[utoipa::path(
post,
path = "/api/v1/auth/change-password",
request_body = ChangePasswordReq,
responses(
(status = 200, description = "密码修改成功,需重新登录"),
(status = 400, description = "当前密码不正确"),
(status = 401, description = "未授权"),
),
security(("bearer_auth" = [])),
tag = "认证"
)]
/// POST /api/v1/auth/change-password
///
/// 修改当前登录用户的密码。修改成功后所有已签发的 refresh token 将被吊销,
/// 用户需要在所有设备上重新登录。
pub async fn change_password<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<ChangePasswordReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
AuthService::change_password(
ctx.user_id,
ctx.tenant_id,
&req.current_password,
&req.new_password,
&state.db,
)
.await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("密码修改成功,请重新登录".to_string()),
}))
}

View File

@@ -0,0 +1,4 @@
pub mod auth_handler;
pub mod org_handler;
pub mod role_handler;
pub mod user_handler;

View File

@@ -0,0 +1,460 @@
use axum::Extension;
use axum::extract::{FromRef, Path, State};
use axum::response::Json;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, TenantContext};
use uuid::Uuid;
use crate::auth_state::AuthState;
use crate::dto::{
CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp,
OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq,
};
use crate::service::dept_service::DeptService;
use crate::service::org_service::OrgService;
use crate::service::position_service::PositionService;
use erp_core::rbac::require_permission;
// --- Organization handlers ---
#[utoipa::path(
get,
path = "/api/v1/organizations",
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<OrganizationResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// GET /api/v1/organizations
///
/// List all organizations within the current tenant as a nested tree.
/// Requires the `organization.list` permission.
pub async fn list_organizations<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<OrganizationResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.list")?;
let tree = OrgService::get_tree(ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(tree)))
}
#[utoipa::path(
post,
path = "/api/v1/organizations",
request_body = CreateOrganizationReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<OrganizationResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// POST /api/v1/organizations
///
/// Create a new organization within the current tenant.
/// Requires the `organization.create` permission.
pub async fn create_organization<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateOrganizationReq>,
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let org = OrgService::create(
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(org)))
}
#[utoipa::path(
put,
path = "/api/v1/organizations/{id}",
params(("id" = Uuid, Path, description = "组织ID")),
request_body = UpdateOrganizationReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<OrganizationResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "组织不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// PUT /api/v1/organizations/{id}
///
/// Update editable organization fields (name, code, sort_order).
/// Requires the `organization.update` permission.
pub async fn update_organization<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateOrganizationReq>,
) -> Result<Json<ApiResponse<OrganizationResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.update")?;
let org = OrgService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
Ok(Json(ApiResponse::ok(org)))
}
#[utoipa::path(
delete,
path = "/api/v1/organizations/{id}",
params(("id" = Uuid, Path, description = "组织ID")),
responses(
(status = 200, description = "组织已删除"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "组织不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// DELETE /api/v1/organizations/{id}
///
/// Soft-delete an organization by ID.
/// Requires the `organization.delete` permission.
pub async fn delete_organization<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "organization.delete")?;
OrgService::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()),
}))
}
// --- Department handlers ---
#[utoipa::path(
get,
path = "/api/v1/organizations/{org_id}/departments",
params(("org_id" = Uuid, Path, description = "组织ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<DepartmentResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// GET /api/v1/organizations/{org_id}/departments
///
/// List all departments for an organization as a nested tree.
/// Requires the `department.list` permission.
pub async fn list_departments<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(org_id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<DepartmentResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.list")?;
let tree = DeptService::list_tree(org_id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(tree)))
}
#[utoipa::path(
post,
path = "/api/v1/organizations/{org_id}/departments",
params(("org_id" = Uuid, Path, description = "组织ID")),
request_body = CreateDepartmentReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<DepartmentResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// POST /api/v1/organizations/{org_id}/departments
///
/// Create a new department under the specified organization.
/// Requires the `department.create` permission.
pub async fn create_department<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(org_id): Path<Uuid>,
Json(req): Json<CreateDepartmentReq>,
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let dept = DeptService::create(
org_id,
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(dept)))
}
#[utoipa::path(
put,
path = "/api/v1/departments/{id}",
params(("id" = Uuid, Path, description = "部门ID")),
request_body = UpdateDepartmentReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<DepartmentResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "部门不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// PUT /api/v1/departments/{id}
///
/// Update editable department fields (name, code, manager_id, sort_order).
/// Requires the `department.update` permission.
pub async fn update_department<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateDepartmentReq>,
) -> Result<Json<ApiResponse<DepartmentResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.update")?;
let dept = DeptService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
Ok(Json(ApiResponse::ok(dept)))
}
#[utoipa::path(
delete,
path = "/api/v1/departments/{id}",
params(("id" = Uuid, Path, description = "部门ID")),
responses(
(status = 200, description = "部门已删除"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "部门不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// DELETE /api/v1/departments/{id}
///
/// Soft-delete a department by ID.
/// Requires the `department.delete` permission.
pub async fn delete_department<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "department.delete")?;
DeptService::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()),
}))
}
// --- Position handlers ---
#[utoipa::path(
get,
path = "/api/v1/departments/{dept_id}/positions",
params(("dept_id" = Uuid, Path, description = "部门ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<PositionResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// GET /api/v1/departments/{dept_id}/positions
///
/// List all positions for a department.
/// Requires the `position.list` permission.
pub async fn list_positions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(dept_id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<PositionResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.list")?;
let positions = PositionService::list(dept_id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(positions)))
}
#[utoipa::path(
post,
path = "/api/v1/departments/{dept_id}/positions",
params(("dept_id" = Uuid, Path, description = "部门ID")),
request_body = CreatePositionReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<PositionResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// POST /api/v1/departments/{dept_id}/positions
///
/// Create a new position under the specified department.
/// Requires the `position.create` permission.
pub async fn create_position<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(dept_id): Path<Uuid>,
Json(req): Json<CreatePositionReq>,
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let pos = PositionService::create(
dept_id,
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(pos)))
}
#[utoipa::path(
put,
path = "/api/v1/positions/{id}",
params(("id" = Uuid, Path, description = "岗位ID")),
request_body = UpdatePositionReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<PositionResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "岗位不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// PUT /api/v1/positions/{id}
///
/// Update editable position fields (name, code, level, sort_order).
/// Requires the `position.update` permission.
pub async fn update_position<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdatePositionReq>,
) -> Result<Json<ApiResponse<PositionResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.update")?;
let pos = PositionService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
Ok(Json(ApiResponse::ok(pos)))
}
#[utoipa::path(
delete,
path = "/api/v1/positions/{id}",
params(("id" = Uuid, Path, description = "岗位ID")),
responses(
(status = 200, description = "岗位已删除"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "岗位不存在"),
),
security(("bearer_auth" = [])),
tag = "组织管理"
)]
/// DELETE /api/v1/positions/{id}
///
/// Soft-delete a position by ID.
/// Requires the `position.delete` permission.
pub async fn delete_position<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "position.delete")?;
PositionService::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()),
}))
}

View File

@@ -0,0 +1,320 @@
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::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
use uuid::Uuid;
use crate::auth_state::AuthState;
use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq};
use crate::service::permission_service::PermissionService;
use crate::service::role_service::RoleService;
use erp_core::rbac::require_permission;
#[utoipa::path(
get,
path = "/api/v1/roles",
params(Pagination),
responses(
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<RoleResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// GET /api/v1/roles
///
/// List roles within the current tenant with pagination.
/// Requires the `role.list` permission.
pub async fn list_roles<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Query(pagination): Query<Pagination>,
) -> Result<Json<ApiResponse<PaginatedResponse<RoleResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.list")?;
let (roles, total) = RoleService::list(ctx.tenant_id, &pagination, &state.db).await?;
let page = pagination.page.unwrap_or(1);
let page_size = pagination.limit();
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: roles,
total,
page,
page_size,
total_pages,
})))
}
#[utoipa::path(
post,
path = "/api/v1/roles",
request_body = CreateRoleReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<RoleResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// POST /api/v1/roles
///
/// Create a new role within the current tenant.
/// Requires the `role.create` permission.
pub async fn create_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateRoleReq>,
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let role = RoleService::create(
ctx.tenant_id,
ctx.user_id,
&req.name,
&req.code,
&req.description,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(role)))
}
#[utoipa::path(
get,
path = "/api/v1/roles/{id}",
params(("id" = Uuid, Path, description = "角色ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<RoleResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "角色不存在"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// GET /api/v1/roles/:id
///
/// Fetch a single role by ID within the current tenant.
/// Requires the `role.read` permission.
pub async fn get_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.read")?;
let role = RoleService::get_by_id(id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(role)))
}
#[utoipa::path(
put,
path = "/api/v1/roles/{id}",
params(("id" = Uuid, Path, description = "角色ID")),
request_body = UpdateRoleReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<RoleResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "角色不存在"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// PUT /api/v1/roles/:id
///
/// Update editable role fields (name, description).
/// Requires the `role.update` permission.
pub async fn update_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateRoleReq>,
) -> Result<Json<ApiResponse<RoleResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.update")?;
let role = RoleService::update(
id,
ctx.tenant_id,
ctx.user_id,
&req.name,
&req.description,
req.version,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(role)))
}
#[utoipa::path(
delete,
path = "/api/v1/roles/{id}",
params(("id" = Uuid, Path, description = "角色ID")),
responses(
(status = 200, description = "角色已删除"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "角色不存在"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// DELETE /api/v1/roles/:id
///
/// Soft-delete a role by ID within the current tenant.
/// System roles cannot be deleted.
/// Requires the `role.delete` permission.
pub async fn delete_role<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.delete")?;
RoleService::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()),
}))
}
#[utoipa::path(
post,
path = "/api/v1/roles/{id}/permissions",
params(("id" = Uuid, Path, description = "角色ID")),
request_body = AssignPermissionsReq,
responses(
(status = 200, description = "权限分配成功"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "角色不存在"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// POST /api/v1/roles/:id/permissions
///
/// Replace all permission assignments for a role.
/// Requires the `role.update` permission.
pub async fn assign_permissions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<AssignPermissionsReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.update")?;
RoleService::assign_permissions(
id,
ctx.tenant_id,
ctx.user_id,
&req.permission_ids,
&state.db,
)
.await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("权限分配成功".to_string()),
}))
}
#[utoipa::path(
get,
path = "/api/v1/roles/{id}/permissions",
params(("id" = Uuid, Path, description = "角色ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<PermissionResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "角色不存在"),
),
security(("bearer_auth" = [])),
tag = "角色管理"
)]
/// GET /api/v1/roles/:id/permissions
///
/// Fetch all permissions assigned to a role.
/// Requires the `role.read` permission.
pub async fn get_role_permissions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "role.read")?;
let perms = RoleService::get_role_permissions(id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(perms)))
}
#[utoipa::path(
get,
path = "/api/v1/permissions",
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<PermissionResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "权限管理"
)]
/// GET /api/v1/permissions
///
/// List all permissions within the current tenant.
/// Requires the `permission.list` permission.
pub async fn list_permissions<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<PermissionResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "permission.list")?;
let perms = PermissionService::list(ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(perms)))
}

View File

@@ -0,0 +1,269 @@
use axum::Extension;
use axum::extract::{FromRef, Path, Query, State};
use axum::response::Json;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use validator::Validate;
use erp_core::error::AppError;
use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext};
use uuid::Uuid;
use crate::auth_state::AuthState;
use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp};
use crate::service::user_service::UserService;
use erp_core::rbac::require_permission;
/// Query parameters for user list endpoint.
#[derive(Debug, Deserialize, IntoParams)]
pub struct UserListParams {
pub page: Option<u64>,
pub page_size: Option<u64>,
/// Optional search term — filters by username (case-insensitive contains).
pub search: Option<String>,
}
#[utoipa::path(
get,
path = "/api/v1/users",
params(UserListParams),
responses(
(status = 200, description = "成功", body = ApiResponse<PaginatedResponse<UserResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// GET /api/v1/users
///
/// List users within the current tenant with pagination and optional search.
/// Requires the `user.list` permission.
pub async fn list_users<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<UserListParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<UserResp>>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.list")?;
let pagination = Pagination {
page: params.page,
page_size: params.page_size,
};
let (users, total) = UserService::list(
ctx.tenant_id,
&pagination,
params.search.as_deref(),
&state.db,
)
.await?;
let page = pagination.page.unwrap_or(1);
let page_size = pagination.limit();
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: users,
total,
page,
page_size,
total_pages,
})))
}
#[utoipa::path(
post,
path = "/api/v1/users",
request_body = CreateUserReq,
responses(
(status = 200, description = "创建成功", body = ApiResponse<UserResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// POST /api/v1/users
///
/// Create a new user within the current tenant.
/// Requires the `user.create` permission.
pub async fn create_user<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<CreateUserReq>,
) -> Result<Json<ApiResponse<UserResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.create")?;
req.validate()
.map_err(|e| AppError::Validation(e.to_string()))?;
let user = UserService::create(
ctx.tenant_id,
ctx.user_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(user)))
}
#[utoipa::path(
get,
path = "/api/v1/users/{id}",
params(("id" = Uuid, Path, description = "用户ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<UserResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "用户不存在"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// GET /api/v1/users/:id
///
/// Fetch a single user by ID within the current tenant.
/// Requires the `user.read` permission.
pub async fn get_user<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<UserResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.read")?;
let user = UserService::get_by_id(id, ctx.tenant_id, &state.db).await?;
Ok(Json(ApiResponse::ok(user)))
}
#[utoipa::path(
put,
path = "/api/v1/users/{id}",
params(("id" = Uuid, Path, description = "用户ID")),
request_body = UpdateUserReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<UserResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "用户不存在"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// PUT /api/v1/users/:id
///
/// Update editable user fields.
/// Requires the `user.update` permission.
pub async fn update_user<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<UpdateUserReq>,
) -> Result<Json<ApiResponse<UserResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.update")?;
let user = UserService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?;
Ok(Json(ApiResponse::ok(user)))
}
#[utoipa::path(
delete,
path = "/api/v1/users/{id}",
params(("id" = Uuid, Path, description = "用户ID")),
responses(
(status = 200, description = "用户已删除"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "用户不存在"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// DELETE /api/v1/users/:id
///
/// Soft-delete a user by ID within the current tenant.
/// Requires the `user.delete` permission.
pub async fn delete_user<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.delete")?;
UserService::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()),
}))
}
/// Assign roles request body.
#[derive(Debug, Deserialize, ToSchema)]
pub struct AssignRolesReq {
pub role_ids: Vec<Uuid>,
}
/// Assign roles response.
#[derive(Debug, Serialize, ToSchema)]
pub struct AssignRolesResp {
pub roles: Vec<RoleResp>,
}
#[utoipa::path(
post,
path = "/api/v1/users/{id}/roles",
params(("id" = Uuid, Path, description = "用户ID")),
request_body = AssignRolesReq,
responses(
(status = 200, description = "角色分配成功", body = ApiResponse<AssignRolesResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "用户不存在"),
),
security(("bearer_auth" = [])),
tag = "用户管理"
)]
/// POST /api/v1/users/:id/roles
///
/// Replace all role assignments for a user within the current tenant.
/// Requires the `user.update` permission.
pub async fn assign_roles<S>(
State(state): State<AuthState>,
Extension(ctx): Extension<TenantContext>,
Path(id): Path<Uuid>,
Json(req): Json<AssignRolesReq>,
) -> Result<Json<ApiResponse<AssignRolesResp>>, AppError>
where
AuthState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "user.update")?;
let roles =
UserService::assign_roles(id, ctx.tenant_id, ctx.user_id, &req.role_ids, &state.db).await?;
Ok(Json(ApiResponse::ok(AssignRolesResp { roles })))
}

View File

@@ -1 +1,11 @@
// erp-auth: 身份与权限模块 (Phase 2)
pub mod auth_state;
pub mod dto;
pub mod entity;
pub mod error;
pub mod handler;
pub mod middleware;
pub mod module;
pub mod service;
pub use auth_state::AuthState;
pub use module::AuthModule;

View File

@@ -0,0 +1,64 @@
use axum::body::Body;
use axum::http::Request;
use axum::middleware::Next;
use axum::response::Response;
use erp_core::error::AppError;
use erp_core::types::TenantContext;
use crate::service::token_service::TokenService;
/// JWT authentication middleware function.
///
/// Extracts the `Bearer` token from the `Authorization` header, validates it
/// using `TokenService::decode_token`, and injects a `TenantContext` into the
/// request extensions so downstream handlers can access tenant/user identity.
///
/// The `jwt_secret` parameter is passed explicitly by the server crate at
/// middleware construction time, avoiding any circular dependency between
/// erp-auth and erp-server.
///
/// # Errors
///
/// Returns `AppError::Unauthorized` if:
/// - The `Authorization` header is missing
/// - The header value does not start with `"Bearer "`
/// - The token cannot be decoded or has expired
/// - The token type is not "access"
pub async fn jwt_auth_middleware_fn(
jwt_secret: String,
req: Request<Body>,
next: Next,
) -> Result<Response, AppError> {
let auth_header = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.ok_or(AppError::Unauthorized)?;
let token = auth_header
.strip_prefix("Bearer ")
.ok_or(AppError::Unauthorized)?;
let claims =
TokenService::decode_token(token, &jwt_secret).map_err(|_| AppError::Unauthorized)?;
// Verify this is an access token, not a refresh token
if claims.token_type != "access" {
return Err(AppError::Unauthorized);
}
let ctx = TenantContext {
tenant_id: claims.tid,
user_id: claims.sub,
roles: claims.roles,
permissions: claims.permissions,
};
// Reconstruct the request with the TenantContext injected into extensions.
// We cannot borrow `req` mutably after reading headers, so we rebuild.
let (parts, body) = req.into_parts();
let mut req = Request::from_parts(parts, body);
req.extensions_mut().insert(ctx);
Ok(next.run(req).await)
}

View File

@@ -0,0 +1,4 @@
pub mod jwt_auth;
pub use erp_core::rbac::{require_any_permission, require_permission, require_role};
pub use jwt_auth::jwt_auth_middleware_fn;

View File

@@ -0,0 +1,196 @@
use axum::Router;
use uuid::Uuid;
use erp_core::error::AppResult;
use erp_core::events::EventBus;
use erp_core::module::ErpModule;
use crate::handler::{auth_handler, org_handler, role_handler, user_handler};
/// Auth module implementing the `ErpModule` trait.
///
/// Manages identity, authentication, and user CRUD within the ERP platform.
/// This module has no dependencies on other business modules.
pub struct AuthModule;
impl AuthModule {
pub fn new() -> Self {
Self
}
/// Build public (unauthenticated) routes for the auth module.
///
/// These routes do not require a valid JWT token.
/// The caller wraps this into whatever state type the application uses.
pub fn public_routes<S>() -> Router<S>
where
crate::auth_state::AuthState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/auth/login", axum::routing::post(auth_handler::login))
.route("/auth/refresh", axum::routing::post(auth_handler::refresh))
}
/// Build protected (authenticated) routes for the auth module.
///
/// These routes require a valid JWT token, verified by the middleware layer.
/// The caller wraps this into whatever state type the application uses.
pub fn protected_routes<S>() -> Router<S>
where
crate::auth_state::AuthState: axum::extract::FromRef<S>,
S: Clone + Send + Sync + 'static,
{
Router::new()
.route("/auth/logout", axum::routing::post(auth_handler::logout))
.route(
"/auth/change-password",
axum::routing::post(auth_handler::change_password),
)
.route(
"/users",
axum::routing::get(user_handler::list_users).post(user_handler::create_user),
)
.route(
"/users/{id}",
axum::routing::get(user_handler::get_user)
.put(user_handler::update_user)
.delete(user_handler::delete_user),
)
.route(
"/users/{id}/roles",
axum::routing::post(user_handler::assign_roles),
)
.route(
"/roles",
axum::routing::get(role_handler::list_roles).post(role_handler::create_role),
)
.route(
"/roles/{id}",
axum::routing::get(role_handler::get_role)
.put(role_handler::update_role)
.delete(role_handler::delete_role),
)
.route(
"/roles/{id}/permissions",
axum::routing::get(role_handler::get_role_permissions)
.post(role_handler::assign_permissions),
)
.route(
"/permissions",
axum::routing::get(role_handler::list_permissions),
)
// Organization routes
.route(
"/organizations",
axum::routing::get(org_handler::list_organizations)
.post(org_handler::create_organization),
)
.route(
"/organizations/{id}",
axum::routing::put(org_handler::update_organization)
.delete(org_handler::delete_organization),
)
// Department routes (nested under organization)
.route(
"/organizations/{org_id}/departments",
axum::routing::get(org_handler::list_departments)
.post(org_handler::create_department),
)
.route(
"/departments/{id}",
axum::routing::put(org_handler::update_department)
.delete(org_handler::delete_department),
)
// Position routes (nested under department)
.route(
"/departments/{dept_id}/positions",
axum::routing::get(org_handler::list_positions).post(org_handler::create_position),
)
.route(
"/positions/{id}",
axum::routing::put(org_handler::update_position)
.delete(org_handler::delete_position),
)
}
}
impl Default for AuthModule {
fn default() -> Self {
Self::new()
}
}
#[async_trait::async_trait]
impl ErpModule for AuthModule {
fn name(&self) -> &str {
"auth"
}
fn version(&self) -> &str {
env!("CARGO_PKG_VERSION")
}
fn dependencies(&self) -> Vec<&str> {
// Auth is a foundational module with no business-module dependencies.
vec![]
}
fn register_event_handlers(&self, _bus: &EventBus) {
// Auth 模块暂无跨模块事件订阅需求
}
async fn on_tenant_created(
&self,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
_event_bus: &EventBus,
) -> AppResult<()> {
let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD")
.unwrap_or_else(|_| "Admin@2026".to_string());
crate::service::seed::seed_tenant_auth(db, tenant_id, &password)
.await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
tracing::info!(tenant_id = %tenant_id, "Tenant auth initialized");
Ok(())
}
async fn on_tenant_deleted(
&self,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AppResult<()> {
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use chrono::Utc;
let now = Utc::now();
// 软删除该租户下所有用户
let users = crate::entity::user::Entity::find()
.filter(crate::entity::user::Column::TenantId.eq(tenant_id))
.filter(crate::entity::user::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
for user_model in users {
let current_version = user_model.version;
let active: crate::entity::user::ActiveModel = user_model.into();
let mut to_update: crate::entity::user::ActiveModel = active;
to_update.deleted_at = Set(Some(now));
to_update.updated_at = Set(now);
to_update.version = Set(current_version + 1);
let _ = to_update
.update(db)
.await
.map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?;
}
tracing::info!(tenant_id = %tenant_id, "Tenant users soft-deleted");
Ok(())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}

View File

@@ -0,0 +1,304 @@
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{LoginResp, RoleResp, UserResp};
use crate::entity::{role, user, user_credential, user_role};
use crate::error::AuthError;
use erp_core::events::EventBus;
use crate::error::AuthResult;
use super::password;
use super::token_service::TokenService;
/// JWT configuration needed for token signing.
pub struct JwtConfig<'a> {
pub secret: &'a str,
pub access_ttl_secs: i64,
pub refresh_ttl_secs: i64,
}
/// Authentication service handling login, token refresh, and logout.
pub struct AuthService;
impl AuthService {
/// Authenticate a user and issue access + refresh tokens.
///
/// Steps:
/// 1. Look up user by tenant + username (soft-delete aware)
/// 2. Verify user status is "active"
/// 3. Fetch the stored password credential
/// 4. Verify password hash
/// 5. Collect roles and permissions
/// 6. Sign JWT tokens
/// 7. Update last_login_at
/// 8. Publish login event
pub async fn login(
tenant_id: Uuid,
username: &str,
password_plain: &str,
db: &sea_orm::DatabaseConnection,
jwt: &JwtConfig<'_>,
event_bus: &EventBus,
) -> AuthResult<LoginResp> {
// 1. Find user by tenant_id + username
let user_model = user::Entity::find()
.filter(user::Column::TenantId.eq(tenant_id))
.filter(user::Column::Username.eq(username))
.filter(user::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::InvalidCredentials)?;
// 2. Check user status
if user_model.status != "active" {
return Err(AuthError::UserDisabled(user_model.status.clone()));
}
// 3. Find password credential
let cred = user_credential::Entity::find()
.filter(user_credential::Column::UserId.eq(user_model.id))
.filter(user_credential::Column::CredentialType.eq("password"))
.filter(user_credential::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::InvalidCredentials)?;
// 4. Verify password
let stored_hash = cred
.credential_data
.as_ref()
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
.ok_or(AuthError::InvalidCredentials)?;
if !password::verify_password(password_plain, stored_hash)? {
return Err(AuthError::InvalidCredentials);
}
// 5. Get roles and permissions
let roles: Vec<String> = TokenService::get_user_roles(user_model.id, tenant_id, db).await?;
let permissions = TokenService::get_user_permissions(user_model.id, tenant_id, db).await?;
// 6. Sign tokens
let access_token = TokenService::sign_access_token(
user_model.id,
tenant_id,
roles.clone(),
permissions,
jwt.secret,
jwt.access_ttl_secs,
)?;
let (refresh_token, _) = TokenService::sign_refresh_token(
user_model.id,
tenant_id,
db,
jwt.secret,
jwt.refresh_ttl_secs,
)
.await?;
// 7. Update last_login_at
let mut user_active: user::ActiveModel = user_model.clone().into();
user_active.last_login_at = Set(Some(Utc::now()));
user_active.updated_at = Set(Utc::now());
user_active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// 8. Build response
let role_resps = Self::get_user_role_resps(user_model.id, tenant_id, db).await?;
let user_resp = UserResp {
id: user_model.id,
username: user_model.username.clone(),
email: user_model.email,
phone: user_model.phone,
display_name: user_model.display_name,
avatar_url: user_model.avatar_url,
status: user_model.status,
roles: role_resps,
version: user_model.version,
};
// 9. Publish event
event_bus.publish(erp_core::events::DomainEvent::new(
"user.login",
tenant_id,
serde_json::json!({ "user_id": user_model.id, "username": user_model.username }),
), db).await;
Ok(LoginResp {
access_token,
refresh_token,
expires_in: jwt.access_ttl_secs as u64,
user: user_resp,
})
}
/// Refresh the token pair: validate the old refresh token, revoke it, issue a new pair.
pub async fn refresh(
refresh_token_str: &str,
db: &sea_orm::DatabaseConnection,
jwt: &JwtConfig<'_>,
) -> AuthResult<LoginResp> {
// Validate existing refresh token
let (old_token_id, claims) =
TokenService::validate_refresh_token(refresh_token_str, db, jwt.secret).await?;
// Revoke the old token (rotation)
TokenService::revoke_token(old_token_id, db).await?;
// Fetch fresh roles and permissions
let roles: Vec<String> = TokenService::get_user_roles(claims.sub, claims.tid, db).await?;
let permissions = TokenService::get_user_permissions(claims.sub, claims.tid, db).await?;
// Sign new token pair
let access_token = TokenService::sign_access_token(
claims.sub,
claims.tid,
roles.clone(),
permissions,
jwt.secret,
jwt.access_ttl_secs,
)?;
let (new_refresh_token, _) = TokenService::sign_refresh_token(
claims.sub,
claims.tid,
db,
jwt.secret,
jwt.refresh_ttl_secs,
)
.await?;
// Fetch user for the response
let user_model = user::Entity::find_by_id(claims.sub)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or(AuthError::TokenRevoked)?;
let role_resps = Self::get_user_role_resps(claims.sub, claims.tid, db).await?;
let user_resp = UserResp {
id: user_model.id,
username: user_model.username,
email: user_model.email,
phone: user_model.phone,
display_name: user_model.display_name,
avatar_url: user_model.avatar_url,
status: user_model.status,
roles: role_resps,
version: user_model.version,
};
Ok(LoginResp {
access_token,
refresh_token: new_refresh_token,
expires_in: jwt.access_ttl_secs as u64,
user: user_resp,
})
}
/// Revoke all refresh tokens for a user, effectively logging them out everywhere.
pub async fn logout(
user_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<()> {
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await
}
/// Change password for the authenticated user.
///
/// Steps:
/// 1. Verify current password
/// 2. Hash the new password
/// 3. Update the credential record
/// 4. Revoke all existing refresh tokens (force re-login)
pub async fn change_password(
user_id: Uuid,
tenant_id: Uuid,
current_password: &str,
new_password: &str,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<()> {
// 1. Find the user's password credential
let cred = user_credential::Entity::find()
.filter(user_credential::Column::UserId.eq(user_id))
.filter(user_credential::Column::TenantId.eq(tenant_id))
.filter(user_credential::Column::CredentialType.eq("password"))
.filter(user_credential::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?;
// 2. Verify current password
let stored_hash = cred
.credential_data
.as_ref()
.and_then(|v| v.get("hash").and_then(|h| h.as_str()))
.ok_or_else(|| AuthError::Validation("用户凭证异常".to_string()))?;
if !password::verify_password(current_password, stored_hash)? {
return Err(AuthError::Validation("当前密码不正确".to_string()));
}
// 3. Hash new password and update credential
let new_hash = password::hash_password(new_password)?;
let mut cred_active: user_credential::ActiveModel = cred.into();
cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash })));
cred_active.updated_at = Set(Utc::now());
cred_active.version = Set(2);
cred_active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
// 4. Revoke all refresh tokens — force re-login on all devices
TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?;
tracing::info!(user_id = %user_id, "Password changed successfully");
Ok(())
}
/// Fetch role details for a user, returning RoleResp DTOs.
async fn get_user_role_resps(
user_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<RoleResp>> {
let user_roles = user_role::Entity::find()
.filter(user_role::Column::UserId.eq(user_id))
.filter(user_role::Column::TenantId.eq(tenant_id))
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
let role_ids: Vec<Uuid> = user_roles.iter().map(|ur| ur.role_id).collect();
if role_ids.is_empty() {
return Ok(vec![]);
}
let roles = role::Entity::find()
.filter(role::Column::Id.is_in(role_ids))
.filter(role::Column::TenantId.eq(tenant_id))
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(roles
.iter()
.map(|r| RoleResp {
id: r.id,
name: r.name.clone(),
code: r.code.clone(),
description: r.description.clone(),
is_system: r.is_system,
version: r.version,
})
.collect())
}
}

View File

@@ -0,0 +1,352 @@
use std::collections::HashMap;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq};
use crate::entity::department;
use crate::entity::organization;
use crate::error::{AuthError, AuthResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
/// Department CRUD service -- create, read, update, soft-delete departments
/// within an organization, supporting tree-structured hierarchy.
pub struct DeptService;
impl DeptService {
/// Fetch all departments for an organization as a nested tree.
///
/// Root departments (parent_id = None) form the top level.
pub async fn list_tree(
org_id: Uuid,
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<DepartmentResp>> {
// Verify the organization exists
let _org = organization::Entity::find_by_id(org_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
let items = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::OrgId.eq(org_id))
.filter(department::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(build_dept_tree(&items))
}
/// Create a new department under the specified organization.
///
/// If `parent_id` is provided, computes `path` from the parent department.
/// Otherwise, path is computed from the organization root.
pub async fn create(
org_id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &CreateDepartmentReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<DepartmentResp> {
// Verify the organization exists
let org = organization::Entity::find_by_id(org_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
// Check code uniqueness within tenant if code is provided
if let Some(ref code) = req.code {
let existing = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::Code.eq(code.as_str()))
.filter(department::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("部门编码已存在".to_string()));
}
}
// Compute path from parent department or organization root
let path = if let Some(parent_id) = req.parent_id {
let parent = department::Entity::find_by_id(parent_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| {
d.tenant_id == tenant_id && d.org_id == org_id && d.deleted_at.is_none()
})
.ok_or_else(|| AuthError::Validation("父级部门不存在".to_string()))?;
let parent_path = parent.path.clone().unwrap_or_default();
Some(format!("{}{}/", parent_path, parent.id))
} else {
// Root department under the organization
let org_path = org.path.clone().unwrap_or_default();
Some(format!("{}{}/", org_path, org.id))
};
let now = Utc::now();
let id = Uuid::now_v7();
let model = department::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
org_id: Set(org_id),
name: Set(req.name.clone()),
code: Set(req.code.clone()),
parent_id: Set(req.parent_id),
manager_id: Set(req.manager_id),
path: Set(path),
sort_order: Set(req.sort_order.unwrap_or(0)),
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| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"department.created",
tenant_id,
serde_json::json!({ "dept_id": id, "org_id": org_id, "name": req.name }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"department.create",
"department",
)
.with_resource_id(id),
db,
)
.await;
Ok(DepartmentResp {
id,
org_id,
name: req.name.clone(),
code: req.code.clone(),
parent_id: req.parent_id,
manager_id: req.manager_id,
path: None,
sort_order: req.sort_order.unwrap_or(0),
children: vec![],
version: 1,
})
}
/// Update editable department fields (name, code, manager_id, sort_order).
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &UpdateDepartmentReq,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<DepartmentResp> {
let model = department::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
// If code is being changed, check uniqueness
if let Some(new_code) = &req.code
&& Some(new_code) != model.code.as_ref()
{
let existing = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::Code.eq(new_code.as_str()))
.filter(department::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("部门编码已存在".to_string()));
}
}
let next_ver = check_version(req.version, model.version)?;
let mut active: department::ActiveModel = model.into();
if let Some(n) = &req.name {
active.name = Set(n.clone());
}
if let Some(c) = &req.code {
active.code = Set(Some(c.clone()));
}
if let Some(mgr_id) = &req.manager_id {
active.manager_id = Set(Some(*mgr_id));
}
if let Some(so) = &req.sort_order {
active.sort_order = Set(*so);
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"department.update",
"department",
)
.with_resource_id(id),
db,
)
.await;
Ok(DepartmentResp {
id: updated.id,
org_id: updated.org_id,
name: updated.name.clone(),
code: updated.code.clone(),
parent_id: updated.parent_id,
manager_id: updated.manager_id,
path: updated.path.clone(),
sort_order: updated.sort_order,
children: vec![],
version: updated.version,
})
}
/// Soft-delete a department by setting the `deleted_at` timestamp.
///
/// Will not delete if child departments exist.
pub async fn delete(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<()> {
let model = department::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?;
// Check for child departments
let children = department::Entity::find()
.filter(department::Column::TenantId.eq(tenant_id))
.filter(department::Column::ParentId.eq(id))
.filter(department::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if children.is_some() {
return Err(AuthError::Validation(
"该部门下存在子部门,无法删除".to_string(),
));
}
let current_version = model.version;
let mut active: department::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(current_version + 1);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"department.deleted",
tenant_id,
serde_json::json!({ "dept_id": id }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"department.delete",
"department",
)
.with_resource_id(id),
db,
)
.await;
Ok(())
}
}
/// Build a nested tree of `DepartmentResp` from a flat list of models.
fn build_dept_tree(items: &[department::Model]) -> Vec<DepartmentResp> {
let mut children_map: HashMap<Option<Uuid>, Vec<&department::Model>> = HashMap::new();
for item in items {
children_map.entry(item.parent_id).or_default().push(item);
}
fn build_node(
item: &department::Model,
map: &HashMap<Option<Uuid>, Vec<&department::Model>>,
) -> DepartmentResp {
let children = map
.get(&Some(item.id))
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
.unwrap_or_default();
DepartmentResp {
id: item.id,
org_id: item.org_id,
name: item.name.clone(),
code: item.code.clone(),
parent_id: item.parent_id,
manager_id: item.manager_id,
path: item.path.clone(),
sort_order: item.sort_order,
children,
version: item.version,
}
}
children_map
.get(&None)
.map(|root_items| {
root_items
.iter()
.map(|item| build_node(item, &children_map))
.collect()
})
.unwrap_or_default()
}

View File

@@ -0,0 +1,10 @@
pub mod auth_service;
pub mod dept_service;
pub mod org_service;
pub mod password;
pub mod permission_service;
pub mod position_service;
pub mod role_service;
pub mod seed;
pub mod token_service;
pub mod user_service;

View File

@@ -0,0 +1,449 @@
use std::collections::HashMap;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use uuid::Uuid;
use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq};
use crate::entity::organization;
use crate::error::{AuthError, AuthResult};
use erp_core::audit::AuditLog;
use erp_core::audit_service;
use erp_core::error::check_version;
use erp_core::events::EventBus;
/// Organization CRUD service -- create, read, update, soft-delete organizations
/// within a tenant, supporting tree-structured hierarchy with path and level.
pub struct OrgService;
impl OrgService {
/// Fetch all organizations for a tenant as a flat list (not deleted).
pub async fn list_flat(
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<organization::Model>> {
let items = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::DeletedAt.is_null())
.all(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
Ok(items)
}
/// Fetch all organizations for a tenant as a nested tree.
///
/// Root nodes have `parent_id = None`. Children are grouped by `parent_id`.
pub async fn get_tree(
tenant_id: Uuid,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<Vec<OrganizationResp>> {
let items = Self::list_flat(tenant_id, db).await?;
Ok(build_org_tree(&items))
}
/// Create a new organization within the current tenant.
///
/// If `parent_id` is provided, computes `path` from the parent's path and id,
/// and sets `level = parent.level + 1`. Otherwise, level defaults to 1.
pub async fn create(
tenant_id: Uuid,
operator_id: Uuid,
req: &CreateOrganizationReq,
db: &sea_orm::DatabaseConnection,
event_bus: &EventBus,
) -> AuthResult<OrganizationResp> {
// Check code uniqueness within tenant if code is provided
if let Some(ref code) = req.code {
let existing = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::Code.eq(code.as_str()))
.filter(organization::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("组织编码已存在".to_string()));
}
}
let (path, level) = if let Some(parent_id) = req.parent_id {
let parent = organization::Entity::find_by_id(parent_id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("父级组织不存在".to_string()))?;
let parent_path = parent.path.clone().unwrap_or_default();
let computed_path = format!("{}{}/", parent_path, parent.id);
(Some(computed_path), parent.level + 1)
} else {
(None, 1)
};
let now = Utc::now();
let id = Uuid::now_v7();
let model = organization::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(req.name.clone()),
code: Set(req.code.clone()),
parent_id: Set(req.parent_id),
path: Set(path),
level: Set(level),
sort_order: Set(req.sort_order.unwrap_or(0)),
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| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"organization.created",
tenant_id,
serde_json::json!({ "org_id": id, "name": req.name }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"organization.create",
"organization",
)
.with_resource_id(id),
db,
)
.await;
Ok(OrganizationResp {
id,
name: req.name.clone(),
code: req.code.clone(),
parent_id: req.parent_id,
path: None,
level,
sort_order: req.sort_order.unwrap_or(0),
children: vec![],
version: 1,
})
}
/// Update editable organization fields (name, code, sort_order).
pub async fn update(
id: Uuid,
tenant_id: Uuid,
operator_id: Uuid,
req: &UpdateOrganizationReq,
db: &sea_orm::DatabaseConnection,
) -> AuthResult<OrganizationResp> {
let model = organization::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
// If code is being changed, check uniqueness
if let Some(ref new_code) = req.code
&& Some(new_code) != model.code.as_ref()
{
let existing = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::Code.eq(new_code.as_str()))
.filter(organization::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if existing.is_some() {
return Err(AuthError::Validation("组织编码已存在".to_string()));
}
}
let next_ver = check_version(req.version, model.version)?;
let mut active: organization::ActiveModel = model.into();
if let Some(ref name) = req.name {
active.name = Set(name.clone());
}
if let Some(ref code) = req.code {
active.code = Set(Some(code.clone()));
}
if let Some(sort_order) = req.sort_order {
active.sort_order = Set(sort_order);
}
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(next_ver);
let updated = active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"organization.update",
"organization",
)
.with_resource_id(id),
db,
)
.await;
Ok(OrganizationResp {
id: updated.id,
name: updated.name.clone(),
code: updated.code.clone(),
parent_id: updated.parent_id,
path: updated.path.clone(),
level: updated.level,
sort_order: updated.sort_order,
children: vec![],
version: updated.version,
})
}
/// Soft-delete an organization 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,
) -> AuthResult<()> {
let model = organization::Entity::find_by_id(id)
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?
.filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none())
.ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?;
// Check for child organizations
let children = organization::Entity::find()
.filter(organization::Column::TenantId.eq(tenant_id))
.filter(organization::Column::ParentId.eq(id))
.filter(organization::Column::DeletedAt.is_null())
.one(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
if children.is_some() {
return Err(AuthError::Validation(
"该组织下存在子组织,无法删除".to_string(),
));
}
let current_version = model.version;
let mut active: organization::ActiveModel = model.into();
active.deleted_at = Set(Some(Utc::now()));
active.updated_at = Set(Utc::now());
active.updated_by = Set(operator_id);
active.version = Set(current_version + 1);
active
.update(db)
.await
.map_err(|e| AuthError::Validation(e.to_string()))?;
event_bus
.publish(
erp_core::events::DomainEvent::new(
"organization.deleted",
tenant_id,
serde_json::json!({ "org_id": id }),
),
db,
)
.await;
audit_service::record(
AuditLog::new(
tenant_id,
Some(operator_id),
"organization.delete",
"organization",
)
.with_resource_id(id),
db,
)
.await;
Ok(())
}
}
/// Build a nested tree of `OrganizationResp` from a flat list of models.
///
/// Root nodes (parent_id = None) form the top level. Each node recursively
/// includes its children grouped by parent_id.
pub(crate) fn build_org_tree(items: &[organization::Model]) -> Vec<OrganizationResp> {
let mut children_map: HashMap<Option<Uuid>, Vec<&organization::Model>> = HashMap::new();
for item in items {
children_map.entry(item.parent_id).or_default().push(item);
}
fn build_node(
item: &organization::Model,
map: &HashMap<Option<Uuid>, Vec<&organization::Model>>,
) -> OrganizationResp {
let children = map
.get(&Some(item.id))
.map(|items| items.iter().map(|i| build_node(i, map)).collect())
.unwrap_or_default();
OrganizationResp {
id: item.id,
name: item.name.clone(),
code: item.code.clone(),
parent_id: item.parent_id,
path: item.path.clone(),
level: item.level,
sort_order: item.sort_order,
children,
version: item.version,
}
}
children_map
.get(&None)
.map(|root_items| {
root_items
.iter()
.map(|item| build_node(item, &children_map))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use chrono::Utc;
use uuid::Uuid;
use crate::entity::organization;
use super::*;
fn make_org(
id: Uuid,
tenant_id: Uuid,
name: &str,
parent_id: Option<Uuid>,
level: i32,
version: i32,
) -> organization::Model {
organization::Model {
id,
tenant_id,
name: name.to_string(),
code: None,
parent_id,
path: None,
level,
sort_order: 0,
created_at: Utc::now(),
updated_at: Utc::now(),
created_by: Uuid::now_v7(),
updated_by: Uuid::now_v7(),
deleted_at: None,
version,
}
}
#[test]
fn build_org_tree_empty() {
let tree = build_org_tree(&[]);
assert!(tree.is_empty());
}
#[test]
fn build_org_tree_single_root() {
let tid = Uuid::now_v7();
let root_id = Uuid::now_v7();
let items = vec![make_org(root_id, tid, "总公司", None, 1, 1)];
let tree = build_org_tree(&items);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].name, "总公司");
assert!(tree[0].children.is_empty());
}
#[test]
fn build_org_tree_multiple_roots() {
let tid = Uuid::now_v7();
let items = vec![
make_org(Uuid::now_v7(), tid, "公司A", None, 1, 1),
make_org(Uuid::now_v7(), tid, "公司B", None, 1, 1),
];
let tree = build_org_tree(&items);
assert_eq!(tree.len(), 2);
}
#[test]
fn build_org_tree_nested_children() {
let tid = Uuid::now_v7();
let root_id = Uuid::now_v7();
let child1_id = Uuid::now_v7();
let child2_id = Uuid::now_v7();
let grandchild_id = Uuid::now_v7();
let items = vec![
make_org(root_id, tid, "总公司", None, 1, 1),
make_org(child1_id, tid, "分公司A", Some(root_id), 2, 1),
make_org(child2_id, tid, "分公司B", Some(root_id), 2, 1),
make_org(grandchild_id, tid, "部门A1", Some(child1_id), 3, 1),
];
let tree = build_org_tree(&items);
assert_eq!(tree.len(), 1); // one root
assert_eq!(tree[0].children.len(), 2); // two children
assert_eq!(tree[0].children[0].children.len(), 1); // one grandchild
assert_eq!(tree[0].children[0].children[0].name, "部门A1");
}
#[test]
fn build_org_tree_deep_nesting() {
let tid = Uuid::now_v7();
let l1 = Uuid::now_v7();
let l2 = Uuid::now_v7();
let l3 = Uuid::now_v7();
let l4 = Uuid::now_v7();
let items = vec![
make_org(l1, tid, "L1", None, 1, 1),
make_org(l2, tid, "L2", Some(l1), 2, 1),
make_org(l3, tid, "L3", Some(l2), 3, 1),
make_org(l4, tid, "L4", Some(l3), 4, 1),
];
let tree = build_org_tree(&items);
assert_eq!(tree.len(), 1);
assert_eq!(tree[0].children[0].children[0].children[0].name, "L4");
}
#[test]
fn build_org_tree_preserves_version() {
let tid = Uuid::now_v7();
let root_id = Uuid::now_v7();
let items = vec![make_org(root_id, tid, "测试", None, 1, 5)];
let tree = build_org_tree(&items);
assert_eq!(tree[0].version, 5);
}
}

Some files were not shown because too many files have changed in this diff Show More