Compare commits

..

9 Commits

Author SHA1 Message Date
iven
5ac8e18d74 fix(web): 修复 visible_when 表达式评估器 !=/||/&& 支持 + 添加 validation 前端校验
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- exprEvaluator: 新增 neq 类型修复 != 操作符被当作 == 处理的 bug
- exprEvaluator: 支持 || 和 && 作为 OR/AND 的别名
- PluginCRUDPage: 读取 field.validation.pattern 添加表单正则校验规则
2026-04-21 00:19:10 +08:00
iven
89fc482d99 feat(web): 采用 UI UX Pro Max Soft UI Evolution 设计系统
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
从 Pinterest 风格切换到 Soft UI Evolution 设计系统,使用 UI UX Pro Max
推理引擎生成适合跨行业 ERP 业务用户的专业设计方案。

设计变更:
- 主色从 Pinterest Red (#e60023) 切换到 Trust Blue (#2563EB)
- 字体从系统默认切换到 Noto Sans SC(中文优先)
- 圆角从 16-20px 调整到 10-12px(专业但不夸张)
- 中性色从暖橄榄调切换到 Slate 石板蓝调
- 成功色 #103c25 → #059669,警告色 #b56e1a → #d97706
- 暗色模式从暖黑 (#1a1a18) 切换到深海军蓝 (#0f172a)

涉及文件:DESIGN.md + index.css + App.tsx + 24 个组件文件
2026-04-20 23:27:24 +08:00
iven
85e732cf12 feat(web): 从 Notion 风格切换到 Pinterest 设计系统
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- 替换 DESIGN.md 为 Pinterest 设计规格(暖色调、红色主题、大圆角)
- 更新 CSS 变量:主色 #0075de→#e60023, 圆角 4px→16px, 背景 #f6f5f4→#f6f6f3
- 更新 Ant Design 主题令牌:更大圆角、Pinterest 色板、更大触控目标
- 批量更新 24 个页面/组件文件中的硬编码颜色值
- 暗色模式同步适配 Pinterest 暖色调暗色方案
2026-04-20 22:13:20 +08:00
iven
8f3d2d58e7 feat(web): 采用 Notion 设计系统 — 暖色调 + 白色侧边栏 + Inter 字体
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
引入 Notion 风格的 DESIGN.md 设计系统文件,并全面重构前端 UI:

- 主色从 Indigo (#4F46E5) 迁移到 Notion Blue (#0075de)
- 页面背景从冷灰 (#F1F5F9) 迁移到暖白 (#f6f5f4)
- 侧边栏从深色 (#0F172A) 迁移到白色,活跃项用蓝色指示
- 文字从 Slate 冷色迁移到暖灰系列 (Warm Gray 500/300)
- 圆角从 8px 缩小到 4px(按钮/输入),8px(卡片)
- 阴影改为多层超轻 Notion 风格(最大 opacity 0.05)
- 字体优先使用 Inter,保留中文回退
- 暗色模式适配暖黑色调 (#191918)
- 更新 27 个前端文件的硬编码颜色值
2026-04-20 13:08:22 +08:00
iven
40b37cc776 feat(plugin,freelance,itops,web): P5-P6 dashboard widgets 平台扩展 + 仪表盘声明
Some checks failed
CI / security-audit (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
P5 平台扩展:
- manifest.rs: Dashboard 变体新增 widgets 字段
- manifest.rs: 定义 PluginWidget/StatCard/ActionQuery 类型
- 前端: 扩展 DashboardWidget 类型支持 stat_cards/action_list/funnel/card_list
- 前端: 新增 4 个 widget 渲染器 (StatCardsWidget/ActionListWidget/FunnelStageWidget/CardListWidget)
- 前端: PluginDashboardPage widget 数据加载支持新类型

P6 仪表盘 widgets:
- freelance: 工作台仪表盘 4 个 widgets (财务概览/紧急待办/商机漏斗/活跃项目)
- itops: 新增运维概览仪表盘 2 个 widgets (运维概览/紧急待办)
2026-04-20 09:35:27 +08:00
iven
301178067c feat(freelance,itops): P1-P4 智能业务引擎 + PDF 模板
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
freelance (P1+P3):
- settings: 7 配置项 (公司/财务/提醒)
- trigger_events: 5 个触发事件 (商机/合同/发票/任务/支出)
- cascade: 5 处级联过滤 (合同→商机/报价, 发票→项目/合同, 工时→任务)
- visible_when: 4 处条件显示 (收款日期/已付金额/实际工时/总金额)
- validation: 2 处校验 (手机号/邮箱)
- templates: 3 个 PDF 模板 (报价单/发票/合同)

itops (P2+P4):
- settings: 4 配置项 (SLA/提醒)
- trigger_events: 4 个触发事件 (工单/合同/巡检)
- cascade: 2 处级联过滤 (工单→合同, 巡检记录→合同)
- visible_when: 6 处条件显示 (解决方案/响应时间/解决时间/关闭时间/问题/措施)
- validation: 1 处校验 (合同编号格式)
- templates: 1 个 PDF 模板 (维保合同)
2026-04-20 09:15:57 +08:00
iven
7e063a7e88 docs(plan): freelance/itops 插件增强实施计划 — P1-P6 六阶段
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
P1-P4 纯 plugin.toml(settings/trigger_events/cascade/visible_when/validation/templates)
P5 平台 manifest.rs + 前端 widgets 扩展
P6 两个插件仪表盘 widgets 声明
2026-04-20 07:14:30 +08:00
iven
bcc6662add docs(spec): 修复 spec 审查问题 — cascade/validation 标注为修改已有字段,dashboard 标注平台依赖
Some checks failed
CI / frontend-build (push) Has been cancelled
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / security-audit (push) Has been cancelled
- CRITICAL: 明确 dashboard widgets 需要平台 manifest.rs 扩展
- HIGH: cascade/validation/visible_when 均改为"已有字段追加属性"
- HIGH: visible_when 提供完整 TOML 语法
- MEDIUM: 模板引擎说明(Handlebars)和关系解析机制
- MEDIUM: itops validation 补充字段上下文
- MEDIUM: itops dashboard 明确插入位置
2026-04-20 00:50:29 +08:00
iven
f4afc969bd docs(spec): 自由职业者/IT运维插件增强设计规格
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled
三层增强方案:智能业务引擎 + 仪表盘重构 + 专业输出
纯插件层实现,无需修改平台代码
2026-04-20 00:43:34 +08:00
36 changed files with 2687 additions and 388 deletions

273
DESIGN.md Normal file
View File

@@ -0,0 +1,273 @@
# Design System — Enterprise ERP Platform
> Generated by UI UX Pro Max | Style: Soft UI Evolution | Target: Cross-industry business users
## 1. Visual Theme & Atmosphere
A warm, professional, and approachable design that feels trustworthy for business users across all industries — manufacturing, retail, finance, services, and more. The design uses a clean white canvas with soft blue as the primary brand color, conveying reliability and clarity without feeling cold or corporate.
The Soft UI Evolution style provides subtle depth through improved shadows — softer than flat design but clearer than neumorphism. This creates a sense of modern polish that invites interaction while maintaining excellent readability and accessibility (WCAG AA+).
**Key Characteristics:**
- Clean white canvas with soft blue accent — trustworthy, professional, warm
- Subtle depth through soft shadows — modern but not flat, not skeuomorphic
- Moderate border-radius (10-12px) — friendly but not playful
- Chinese-first typography with Noto Sans SC — readable at all sizes
- Two-tier token system: CSS Variables (`--erp-*`) + Ant Design ConfigProvider
- Dual theme support: light (default) + dark mode
## 2. Color Palette & Roles
### Primary Brand
- **Primary Blue** (`#2563EB`): Primary CTA, active states, links, brand accent
- **Primary Hover** (`#1D4ED8`): Pressed/active primary state
- **Primary Light** (`#EFF6FF`): Primary background tint, subtle highlights
- **Primary Subtle** (`#DBEAFE`): Light blue backgrounds for badges, chips
### Semantic Colors
- **Success Green** (`#059669`): Success states, positive indicators, confirmations
- **Success Light** (`#ECFDF5`): Success background tint
- **Warning Amber** (`#D97706`): Warnings, pending states, attention needed
- **Warning Light** (`#FFFBEB`): Warning background tint
- **Error Red** (`#DC2626`): Errors, destructive actions, required fields
- **Error Light** (`#FEF2F2`): Error background tint
- **Info Blue** (`#0284C7`): Informational elements, tooltips, help text
### Text
- **Primary Text** (`#0F172A`): Headings, primary body text — deep navy, professional
- **Secondary Text** (`#475569`): Descriptions, labels, helper text
- **Tertiary Text** (`#94A3B8`): Placeholders, disabled text, timestamps
- **Inverse Text** (`#FFFFFF`): Text on colored/dark surfaces
### Surface & Border
- **Page Background** (`#F8FAFC`): App background — cool off-white
- **Container Background** (`#FFFFFF`): Cards, panels, modals
- **Elevated Background** (`#FFFFFF`): Dropdowns, popovers, tooltips
- **Border** (`#E2E8F0`): Default borders, dividers
- **Border Strong** (`#CBD5E1`): Emphasized borders, active card outlines
- **Hover Background** (`#F1F5F9`): Row hover, item hover backgrounds
- **Muted Background** (`#F1F5F9`): Subtle section backgrounds, table headers
### Dark Mode
- **Dark Page** (`#0F172A`): Dark app background
- **Dark Container** (`#1E293B`): Dark cards, panels
- **Dark Elevated** (`#334155`): Dark dropdowns, popovers
- **Dark Border** (`#334155`): Dark borders
- **Dark Hover** (`#1E293B`): Dark row hover
- **Dark Text Primary** (`#F8FAFC`): Dark mode primary text
- **Dark Text Secondary** (`#94A3B8`): Dark mode secondary text
## 3. Typography Rules
### Font Family
- **Primary**: `Noto Sans SC`, fallbacks: `-apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif`
- **Monospace (optional)**: `'JetBrains Mono', 'Fira Code', Consolas, monospace`
### Hierarchy
| Role | Size | Weight | Line Height | Usage |
|------|------|--------|-------------|-------|
| Page Title | 24px (1.5rem) | 700 | 1.3 | Page headings, dialog titles |
| Section Title | 20px (1.25rem) | 600 | 1.4 | Section headings, card titles |
| Subsection | 16px (1rem) | 600 | 1.5 | Subsection labels, form group titles |
| Body | 14px (0.875rem) | 400 | 1.6 | Standard body text, table cells, descriptions |
| Caption | 12px (0.75rem) | 400 | 1.5 | Timestamps, badges, helper text, metadata |
### Principles
- **Chinese-first**: Noto Sans SC ensures excellent CJK rendering
- **Readable weights**: 400 for body, 600-700 for headings — always substantial, never thin
- **Generous line-height**: 1.5-1.6 for body text ensures comfortable reading
- **Moderate scale**: 12-24px range creates a compact, professional information hierarchy
## 4. Component Stylings
### Buttons
- **Primary**: Blue (#2563EB) background, white text, 10px radius, soft shadow
- **Secondary**: White background, slate border (#CBD5E1), dark text, 10px radius
- **Ghost**: Transparent background, primary blue text, no border
- **Danger**: Red (#DC2626) background, white text, for destructive actions
- **All buttons**: Min height 36px, 40px preferred; smooth hover transition 200ms
### Cards & Containers
- White background, 12px radius, soft shadow: `0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)`
- Hover: Slightly elevated shadow: `0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05)`
- No thick borders — shadows and subtle background differences create depth
### Inputs
- White background, 1px solid #CBD5E1 border, 10px radius
- Focus: Blue ring (2px solid #2563EB) with outer glow
- Error: Red border + error message below field
- Height: 40px standard, 32px small
### Tables
- Header: #F1F5F9 background, #475569 text, 14px weight 600
- Row hover: #F1F5F9 background
- Cell padding: 16px vertical, 12px horizontal
- Border: Bottom-only using #E2E8F0
### Navigation / Sidebar
- Background: White (#FFFFFF) with right border #E2E8F0
- Active item: #EFF6FF background, #2563EB text, left accent bar (3px)
- Hover item: #F1F5F9 background
- Item height: 40px, 12px radius
- Icons: 20px, inline with label text
### Tags / Badges
- Small radius (6px), medium padding (4px 8px)
- Color-coded backgrounds: blue tint, green tint, amber tint, red tint
- Text matches semantic color (darker shade than background)
### Modals / Dialogs
- 16px radius, generous padding (24px)
- Soft elevated shadow: `0 8px 30px rgba(0,0,0,0.12)`
- Header with title, footer with action buttons
## 5. Layout Principles
### Spacing System
- Base unit: 4px
- Scale: 4px, 8px, 12px, 16px, 20px, 24px, 32px, 40px, 48px, 64px
- Content padding: 24px standard, 16px compact
### Layout
- Fixed sidebar: 240px wide, collapsible to 72px
- Sticky header: 56px
- Content area: Fluid width with max comfortable reading width
- Standard CRUD table/list views for data management
### Border Radius Scale
- Small (6px): Tags, badges, small elements
- Standard (10px): Buttons, inputs, cards
- Large (12px): Panels, modals, large containers
- Full (50%): Circular avatars, icon buttons
## 6. Depth & Elevation
| Level | Shadow | Use |
|-------|--------|-----|
| Level 0 (Flat) | None | Page background, sidebar |
| Level 1 (Subtle) | `0 1px 2px rgba(0,0,0,0.05)` | Cards, form sections |
| Level 2 (Default) | `0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)` | Elevated cards, dropdowns |
| Level 3 (Elevated) | `0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05)` | Hover states, active cards |
| Level 4 (Modal) | `0 8px 30px rgba(0,0,0,0.12)` | Modals, overlays |
## 7. Interactive States
### Hover
- Background color shift: `#F1F5F9` for neutral, semantic tint for colored elements
- Transition: 200ms ease
- Cursor: `pointer` on clickable elements
### Focus
- 2px solid ring using `#2563EB`
- 2px offset for visibility
- Always visible for keyboard navigation
### Active / Pressed
- Slightly darker shade of the element's color
- Brief scale or shadow change for tactile feedback
### Disabled
- Reduced opacity (0.5)
- No hover effects
- Cursor: `not-allowed`
### Loading
- Spinner or skeleton placeholder
- Disabled interactions during async operations
## 8. Do's and Don'ts
### Do
- Use soft shadows for depth — the Soft UI Evolution identity
- Apply Primary Blue (#2563EB) only for primary actions and active states
- Use Noto Sans SC for consistent Chinese rendering
- Apply 10-12px radius — friendly but professional
- Use semantic color tints for status backgrounds
- Keep tables clean with subtle header differentiation
- Ensure 4.5:1 contrast ratio for all text
### Don't
- Don't use heavy or dramatic shadows — keep depth subtle
- Don't use pure black (#000000) for text — use #0F172A instead
- Don't use pill-shaped buttons — 10px radius is rounded but not pill
- Don't mix warm and cool neutrals — stay in the slate family
- Don't use emojis as icons — use SVG icons (Lucide/Heroicons)
- Don't use decorative-only animations — every animation must serve a purpose
- Don't use colors as the sole means of conveying information
## 9. Responsive Behavior
### Breakpoints
| Name | Width | Key Changes |
|------|-------|-------------|
| Mobile | <576px | Sidebar collapsed, single column |
| Tablet | 576-768px | Sidebar collapsed, 2-column grid |
| Desktop Small | 768-1024px | Sidebar expanded, responsive grid |
| Desktop | 1024-1440px | Full layout |
| Large Desktop | >1440px | Maximum content width |
### Collapsing Strategy
- Sidebar: 240px → 72px (icon only) on mobile/tablet
- Tables: Horizontal scroll on narrow screens
- Forms: Single column on mobile, multi-column on desktop
- Cards: Full-width stack → grid layout
## 10. ERP Platform Adaptations
### Layout
- Fixed sidebar navigation (240px wide, collapsible to 72px)
- Sticky header (56px) with search, notifications, user menu
- Content area uses CSS Grid for responsive multi-column dashboards
- Standard CRUD table/list views replace masonry grid for data management
### Color Adaptations
- Primary Blue (#2563EB) for primary actions (Save, Create, Submit)
- Success Green (#059669) for positive states (Approved, Completed, Paid)
- Warning Amber (#D97706) for pending states (Pending Review, Awaiting)
- Error Red (#DC2626) for destructive/error states (Rejected, Failed, Overdue)
- Info Blue (#0284C7) for informational elements (Tips, Help, Documentation)
### Component Adaptations
- **Tables**: Slate header bg (#F1F5F9), generous cell padding, subtle hover (#F1F5F9)
- **Forms**: 10px radius inputs, slate borders (#CBD5E1), generous spacing
- **Cards**: 12px radius, soft shadow, white background
- **Sidebar**: Blue active states (#EFF6FF bg), slate hover, 12px radius items
- **Tags/Badges**: 6px radius, semantic color tints (blue/green/amber/red)
- **Modals**: 16px radius, elevated shadow, generous padding
### Dark Mode
- Background: #0F172A (deep navy)
- Container: #1E293B (dark slate)
- Elevated: #334155 (medium slate)
- Border: #334155
- Active accent: #3B82F6 (lighter blue for dark backgrounds)
- Text: #F8FAFC primary, #94A3B8 secondary
## 11. Agent Prompt Guide
### Quick Color Reference
- Brand: Primary Blue (#2563EB)
- Background: Cool Off-White (#F8FAFC)
- Text: Deep Navy (#0F172A)
- Secondary text: Slate (#475569)
- Border: Light Slate (#E2E8F0)
- Success: Green (#059669)
- Warning: Amber (#D97706)
- Error: Red (#DC2626)
- Focus: Blue (#2563EB)
### Example Component Prompts
- "Create a card: white background, 12px radius, soft shadow (0 1px 3px rgba(0,0,0,0.06)). Title in 16px weight 600 #0F172A. Body in 14px #475569."
- "Design a primary button: #2563EB background, white text, 10px radius, 8px 16px padding. Hover: #1D4ED8. Focus: 2px solid #2563EB ring."
- "Build a table: header #F1F5F9 background, #475569 text 14px weight 600. Row hover #F1F5F9. Cell padding 16px 12px. Border bottom #E2E8F0."
- "Create a sidebar: white background, right border #E2E8F0. Active item: #EFF6FF background, #2563EB text, 3px left accent bar. Hover: #F1F5F9."
- "Design a modal: 16px radius, shadow 0 8px 30px rgba(0,0,0,0.12). Title 20px weight 600. Footer with primary blue CTA."
### Iteration Guide
1. Soft shadows everywhere — subtle depth is the identity
2. Primary Blue for CTAs and active states — trustworthy, not overwhelming
3. 10px radius on buttons/inputs, 12px on cards — friendly but professional
4. Noto Sans SC is the primary font — Chinese-first, readable at all sizes
5. Slate neutrals — never pure black or pure gray, always with blue undertone
6. Semantic tints for status — green/amber/red backgrounds with matching text

View File

@@ -31,44 +31,44 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
const themeConfig = { const themeConfig = {
token: { token: {
colorPrimary: '#4F46E5', colorPrimary: '#2563eb',
colorSuccess: '#059669', colorSuccess: '#059669',
colorWarning: '#D97706', colorWarning: '#d97706',
colorError: '#DC2626', colorError: '#dc2626',
colorInfo: '#2563EB', colorInfo: '#0284c7',
colorBgLayout: '#F1F5F9', colorBgLayout: '#f8fafc',
colorBgContainer: '#FFFFFF', colorBgContainer: '#ffffff',
colorBgElevated: '#FFFFFF', colorBgElevated: '#ffffff',
colorBorder: '#E2E8F0', colorBorder: '#e2e8f0',
colorBorderSecondary: '#F1F5F9', colorBorderSecondary: '#f1f5f9',
borderRadius: 8, borderRadius: 10,
borderRadiusLG: 12, borderRadiusLG: 12,
borderRadiusSM: 6, borderRadiusSM: 6,
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', 'Hiragino Sans GB', 'Helvetica Neue', Helvetica, Arial, sans-serif", fontFamily: "'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif",
fontSize: 14, fontSize: 14,
fontSizeHeading4: 20, fontSizeHeading4: 20,
controlHeight: 36, controlHeight: 40,
controlHeightLG: 40, controlHeightLG: 44,
controlHeightSM: 28, controlHeightSM: 32,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06)', boxShadow: 'none',
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.07)', boxShadowSecondary: '0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
}, },
components: { components: {
Button: { Button: {
primaryShadow: '0 1px 2px 0 rgba(79, 70, 229, 0.3)', primaryShadow: 'none',
fontWeight: 500, fontWeight: 500,
}, },
Card: { Card: {
paddingLG: 20, paddingLG: 20,
}, },
Table: { Table: {
headerBg: '#F8FAFC', headerBg: '#f1f5f9',
headerColor: '#475569', headerColor: '#475569',
rowHoverBg: '#F5F3FF', rowHoverBg: '#f1f5f9',
fontSize: 14, fontSize: 14,
}, },
Menu: { Menu: {
itemBorderRadius: 8, itemBorderRadius: 10,
itemMarginInline: 8, itemMarginInline: 8,
itemHeight: 40, itemHeight: 40,
}, },
@@ -85,20 +85,20 @@ const darkThemeConfig = {
...themeConfig, ...themeConfig,
token: { token: {
...themeConfig.token, ...themeConfig.token,
colorBgLayout: '#0B0F1A', colorBgLayout: '#0f172a',
colorBgContainer: '#111827', colorBgContainer: '#1e293b',
colorBgElevated: '#1E293B', colorBgElevated: '#334155',
colorBorder: '#1E293B', colorBorder: '#334155',
colorBorderSecondary: '#1E293B', colorBorderSecondary: 'rgba(255, 255, 255, 0.06)',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.3)', boxShadow: 'none',
boxShadowSecondary: '0 4px 6px -1px rgba(0, 0, 0, 0.4)', boxShadowSecondary: '0 2px 8px rgba(0,0,0,0.3), 0 1px 3px rgba(0,0,0,0.2)',
}, },
components: { components: {
...themeConfig.components, ...themeConfig.components,
Table: { Table: {
headerBg: '#1E293B', headerBg: '#1e293b',
headerColor: '#94A3B8', headerColor: '#94a3b8',
rowHoverBg: '#1E293B', rowHoverBg: '#1e293b',
}, },
}, },
}; };

View File

@@ -199,7 +199,8 @@ export type PluginPageSchema =
}; };
export interface DashboardWidget { export interface DashboardWidget {
type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart'; type: 'stat_card' | 'bar_chart' | 'pie_chart' | 'funnel_chart' | 'line_chart'
| 'stat_cards' | 'action_list' | 'funnel' | 'card_list';
entity: string; entity: string;
title: string; title: string;
icon?: string; icon?: string;
@@ -207,6 +208,44 @@ export interface DashboardWidget {
dimension_field?: string; dimension_field?: string;
dimension_order?: string[]; dimension_order?: string[];
metric?: string; metric?: string;
// stat_cards
cards?: StatCardDef[];
// action_list
max_items?: number;
queries?: ActionQueryDef[];
// funnel
lane_field?: string;
value_field?: string;
lane_order?: string[];
// card_list
filter?: string;
title_field?: string;
subtitle_field?: string;
tags?: string[];
label?: string;
label_field?: string;
action?: string;
sort?: string;
}
export interface StatCardDef {
entity: string;
aggregate?: string;
field?: string;
filter?: string;
label: string;
icon?: string;
color?: string;
}
export interface ActionQueryDef {
entity: string;
filter?: string;
sort?: string;
label_field: string;
subtitle_field?: string;
action: string;
icon?: string;
} }
export type PluginSectionSchema = export type PluginSectionSchema =

View File

@@ -50,7 +50,7 @@ export default function NotificationPanel() {
<Button <Button
type="text" type="text"
size="small" size="small"
style={{ fontSize: 12, color: '#4F46E5' }} style={{ fontSize: 12, color: '#2563eb' }}
onClick={() => navigate('/messages')} onClick={() => navigate('/messages')}
> >
@@ -76,7 +76,7 @@ export default function NotificationPanel() {
cursor: 'pointer', cursor: 'pointer',
transition: 'background 0.15s ease', transition: 'background 0.15s ease',
border: 'none', border: 'none',
background: !item.is_read ? (isDark ? '#1E293B' : '#F5F3FF') : 'transparent', background: !item.is_read ? (isDark ? '#0f172a' : '#eff6ff') : 'transparent',
}} }}
onClick={() => { onClick={() => {
if (!item.is_read) { if (!item.is_read) {
@@ -85,7 +85,7 @@ export default function NotificationPanel() {
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (item.is_read) { if (item.is_read) {
e.currentTarget.style.background = isDark ? '#1E293B' : '#F8FAFC'; e.currentTarget.style.background = isDark ? '#0f172a' : '#f1f5f9';
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
@@ -109,7 +109,7 @@ export default function NotificationPanel() {
width: 6, width: 6,
height: 6, height: 6,
borderRadius: '50%', borderRadius: '50%',
background: '#4F46E5', background: '#2563eb',
flexShrink: 0, flexShrink: 0,
}} /> }} />
)} )}
@@ -132,12 +132,12 @@ export default function NotificationPanel() {
textAlign: 'center', textAlign: 'center',
paddingTop: 8, paddingTop: 8,
marginTop: 4, marginTop: 4,
borderTop: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, borderTop: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}> }}>
<Button <Button
type="text" type="text"
onClick={() => navigate('/messages')} onClick={() => navigate('/messages')}
style={{ fontSize: 13, color: '#4F46E5', fontWeight: 500 }} style={{ fontSize: 13, color: '#2563eb', fontWeight: 500 }}
> >
</Button> </Button>
@@ -166,7 +166,7 @@ export default function NotificationPanel() {
position: 'relative', position: 'relative',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.background = isDark ? '#1E293B' : '#F1F5F9'; e.currentTarget.style.background = isDark ? '#0f172a' : '#f8fafc';
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'; e.currentTarget.style.background = 'transparent';
@@ -175,7 +175,7 @@ export default function NotificationPanel() {
<Badge count={unreadCount} size="small" offset={[4, -4]}> <Badge count={unreadCount} size="small" offset={[4, -4]}>
<BellOutlined style={{ <BellOutlined style={{
fontSize: 16, fontSize: 16,
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
}} /> }} />
</Badge> </Badge>
</div> </div>

View File

@@ -2,65 +2,66 @@
/* ==================================================================== /* ====================================================================
* ERP Platform — Design System Tokens & Global Styles * ERP Platform — Design System Tokens & Global Styles
* Inspired by Linear, Feishu, SAP Fiori modern design language * Soft UI Evolution: Professional, warm, accessible for all industries
* Generated by UI UX Pro Max
* ==================================================================== */ * ==================================================================== */
/* --- Design Tokens (CSS Custom Properties) --- */ /* --- Design Tokens (CSS Custom Properties) --- */
:root { :root {
/* Primary Palette */ /* Primary Palette — Trust Blue */
--erp-primary: #4F46E5; --erp-primary: #2563eb;
--erp-primary-hover: #4338CA; --erp-primary-hover: #1d4ed8;
--erp-primary-active: #3730A3; --erp-primary-active: #1e40af;
--erp-primary-light: #EEF2FF; --erp-primary-light: #eff6ff;
--erp-primary-light-hover: #E0E7FF; --erp-primary-light-hover: #dbeafe;
--erp-primary-bg-subtle: #F5F3FF; --erp-primary-bg-subtle: #eff6ff;
/* Semantic Colors */ /* Semantic Colors — Professional slate tones */
--erp-success: #059669; --erp-success: #059669;
--erp-success-bg: #ECFDF5; --erp-success-bg: #ecfdf5;
--erp-warning: #D97706; --erp-warning: #d97706;
--erp-warning-bg: #FFFBEB; --erp-warning-bg: #fffbeb;
--erp-error: #DC2626; --erp-error: #dc2626;
--erp-error-bg: #FEF2F2; --erp-error-bg: #fef2f2;
--erp-info: #2563EB; --erp-info: #0284c7;
--erp-info-bg: #EFF6FF; --erp-info-bg: #f0f9ff;
/* Neutral Palette */ /* Neutral Palette — Slate neutrals with blue undertones */
--erp-bg-page: #F1F5F9; --erp-bg-page: #f8fafc;
--erp-bg-container: #FFFFFF; --erp-bg-container: #ffffff;
--erp-bg-elevated: #FFFFFF; --erp-bg-elevated: #ffffff;
--erp-bg-spotlight: #F8FAFC; --erp-bg-spotlight: #f1f5f9;
--erp-bg-sidebar: #0F172A; --erp-bg-sidebar: #ffffff;
--erp-bg-sidebar-hover: #1E293B; --erp-bg-sidebar-hover: #f1f5f9;
--erp-bg-sidebar-active: rgba(79, 70, 229, 0.15); --erp-bg-sidebar-active: #eff6ff;
/* Text Colors */ /* Text Colors — Deep navy */
--erp-text-primary: #0F172A; --erp-text-primary: #0f172a;
--erp-text-secondary: #475569; --erp-text-secondary: #475569;
--erp-text-tertiary: #94A3B8; --erp-text-tertiary: #94a3b8;
--erp-text-inverse: #F8FAFC; --erp-text-inverse: #ffffff;
--erp-text-sidebar: #CBD5E1; --erp-text-sidebar: #475569;
--erp-text-sidebar-active: #FFFFFF; --erp-text-sidebar-active: #2563eb;
/* Border Colors */ /* Border Colors — Slate borders */
--erp-border: #E2E8F0; --erp-border: #e2e8f0;
--erp-border-light: #F1F5F9; --erp-border-light: #f1f5f9;
--erp-border-dark: #334155; --erp-border-dark: #cbd5e1;
/* Shadows */ /* Shadows — Soft UI Evolution: subtle, layered depth */
--erp-shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.03); --erp-shadow-xs: 0 1px 2px rgba(0,0,0,0.05);
--erp-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.06), 0 1px 2px -1px rgba(0, 0, 0, 0.06); --erp-shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
--erp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.07); --erp-shadow-md: 0 4px 6px rgba(0,0,0,0.07), 0 2px 4px rgba(0,0,0,0.05);
--erp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.08); --erp-shadow-lg: 0 8px 30px rgba(0,0,0,0.12);
--erp-shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.08); --erp-shadow-xl: 0 12px 40px rgba(0,0,0,0.15);
/* Radius */ /* Radius — Soft UI: friendly but professional */
--erp-radius-sm: 6px; --erp-radius-sm: 6px;
--erp-radius-md: 8px; --erp-radius-md: 10px;
--erp-radius-lg: 12px; --erp-radius-lg: 12px;
--erp-radius-xl: 16px; --erp-radius-xl: 16px;
/* Spacing */ /* Spacing — 4px base unit */
--erp-space-xs: 4px; --erp-space-xs: 4px;
--erp-space-sm: 8px; --erp-space-sm: 8px;
--erp-space-md: 16px; --erp-space-md: 16px;
@@ -68,11 +69,10 @@
--erp-space-xl: 32px; --erp-space-xl: 32px;
--erp-space-2xl: 48px; --erp-space-2xl: 48px;
/* Typography */ /* Typography — Noto Sans SC for Chinese-first ERP */
--erp-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', --erp-font-family: 'Noto Sans SC', -apple-system, system-ui, 'Segoe UI', Roboto,
'Microsoft YaHei', 'Hiragino Sans GB', 'Helvetica Neue', Helvetica, Arial, sans-serif; 'PingFang SC', 'Microsoft YaHei', Helvetica, Arial, sans-serif;
--erp-font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', Menlo, Monaco, Consolas, --erp-font-mono: 'JetBrains Mono', 'Fira Code', Consolas, Monaco, monospace;
'Liberation Mono', 'Courier New', monospace;
--erp-font-size-xs: 12px; --erp-font-size-xs: 12px;
--erp-font-size-sm: 13px; --erp-font-size-sm: 13px;
--erp-font-size-base: 14px; --erp-font-size-base: 14px;
@@ -88,8 +88,8 @@
/* Trend Colors */ /* Trend Colors */
--erp-trend-up: #059669; --erp-trend-up: #059669;
--erp-trend-down: #DC2626; --erp-trend-down: #dc2626;
--erp-trend-neutral: #64748B; --erp-trend-neutral: #475569;
/* Line Height */ /* Line Height */
--erp-line-height-tight: 1.25; --erp-line-height-tight: 1.25;
@@ -104,34 +104,46 @@
/* --- Dark Mode Tokens --- */ /* --- Dark Mode Tokens --- */
[data-theme='dark'] { [data-theme='dark'] {
--erp-bg-page: #0B0F1A; --erp-primary-light: rgba(37, 99, 235, 0.15);
--erp-bg-container: #111827; --erp-primary-light-hover: rgba(37, 99, 235, 0.22);
--erp-bg-elevated: #1E293B; --erp-primary-bg-subtle: rgba(37, 99, 235, 0.1);
--erp-bg-spotlight: #1E293B;
--erp-bg-sidebar: #070B14;
--erp-bg-sidebar-hover: #111827;
--erp-text-primary: #F1F5F9; --erp-bg-page: #0f172a;
--erp-text-secondary: #94A3B8; --erp-bg-container: #1e293b;
--erp-text-tertiary: #64748B; --erp-bg-elevated: #334155;
--erp-bg-spotlight: #1e293b;
--erp-bg-sidebar: #0f172a;
--erp-bg-sidebar-hover: #1e293b;
--erp-border: #1E293B; --erp-text-primary: rgba(255, 255, 255, 0.95);
--erp-border-light: #1E293B; --erp-text-secondary: #94a3b8;
--erp-text-tertiary: #64748b;
--erp-text-sidebar: #94a3b8;
--erp-text-sidebar-active: #60a5fa;
--erp-shadow-xs: 0 1px 2px 0 rgba(0, 0, 0, 0.3); --erp-border: #334155;
--erp-shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px -1px rgba(0, 0, 0, 0.3); --erp-border-light: rgba(255, 255, 255, 0.06);
--erp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3); --erp-border-dark: rgba(255, 255, 255, 0.12);
--erp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--erp-trend-up: #34D399; --erp-shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--erp-trend-down: #F87171; --erp-shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2);
--erp-trend-neutral: #94A3B8; --erp-shadow-md: 0 4px 12px rgba(0, 0, 0, 0.3), 0 2px 6px rgba(0, 0, 0, 0.2);
--erp-shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.4);
--erp-trend-up: #34d399;
--erp-trend-down: #f87171;
--erp-trend-neutral: #94a3b8;
--erp-success-bg: rgba(5, 150, 105, 0.15);
--erp-warning-bg: rgba(217, 119, 6, 0.15);
--erp-error-bg: rgba(220, 38, 38, 0.15);
--erp-info-bg: rgba(2, 132, 199, 0.15);
} }
[data-theme='dark'] .erp-stat-card-trend-up { color: #34D399; } [data-theme='dark'] .erp-stat-card-trend-up { color: #34d399; }
[data-theme='dark'] .erp-stat-card-trend-down { color: #FCA5A5; } [data-theme='dark'] .erp-stat-card-trend-down { color: #f87171; }
[data-theme='dark'] .erp-stat-card-trend-neutral { color: #94A3B8; } [data-theme='dark'] .erp-stat-card-trend-neutral { color: #94a3b8; }
[data-theme='dark'] .erp-stat-card-trend-label { color: #94A3B8; } [data-theme='dark'] .erp-stat-card-trend-label { color: #94a3b8; }
/* --- Global Reset & Base --- */ /* --- Global Reset & Base --- */
body { body {
@@ -170,20 +182,20 @@ body {
/* --- Selection --- */ /* --- Selection --- */
::selection { ::selection {
background-color: var(--erp-primary-light); background-color: rgba(37, 99, 235, 0.15);
color: var(--erp-primary); color: var(--erp-text-primary);
} }
/* ==================================================================== /* ====================================================================
* Component Overrides — Ant Design Enhancement * Component Overrides — Ant Design Enhancement
* ==================================================================== */ * ==================================================================== */
/* --- Card --- */ /* --- Card — Soft shadow, clean border --- */
.ant-card { .ant-card {
border-radius: var(--erp-radius-lg) !important; border-radius: var(--erp-radius-lg) !important;
border: 1px solid var(--erp-border-light) !important; border: 1px solid var(--erp-border) !important;
box-shadow: var(--erp-shadow-xs) !important; box-shadow: var(--erp-shadow-xs) !important;
transition: box-shadow var(--erp-transition-base), transform var(--erp-transition-base) !important; transition: box-shadow var(--erp-transition-base) !important;
} }
.ant-card:hover { .ant-card:hover {
@@ -209,15 +221,14 @@ body {
/* --- Statistic Cards --- */ /* --- Statistic Cards --- */
.stat-card { .stat-card {
border-radius: var(--erp-radius-lg) !important; border-radius: var(--erp-radius-lg) !important;
border: none !important; border: 1px solid var(--erp-border) !important;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
transition: all var(--erp-transition-base) !important; transition: box-shadow var(--erp-transition-base) !important;
} }
.stat-card:hover { .stat-card:hover {
transform: translateY(-2px) !important; box-shadow: var(--erp-shadow-sm) !important;
box-shadow: var(--erp-shadow-md) !important;
} }
.stat-card .ant-statistic-title { .stat-card .ant-statistic-title {
@@ -251,7 +262,7 @@ body {
} }
.ant-table-tbody > tr:hover > td { .ant-table-tbody > tr:hover > td {
background: var(--erp-primary-bg-subtle) !important; background: var(--erp-bg-spotlight) !important;
} }
.ant-table-tbody > tr > td { .ant-table-tbody > tr > td {
@@ -263,13 +274,12 @@ body {
.ant-btn-primary { .ant-btn-primary {
border-radius: var(--erp-radius-md) !important; border-radius: var(--erp-radius-md) !important;
font-weight: 500 !important; font-weight: 500 !important;
box-shadow: 0 1px 2px 0 rgba(79, 70, 229, 0.3) !important; box-shadow: none !important;
transition: all var(--erp-transition-fast) !important; transition: all var(--erp-transition-fast) !important;
} }
.ant-btn-primary:hover { .ant-btn-primary:hover {
box-shadow: 0 2px 4px 0 rgba(79, 70, 229, 0.4) !important; opacity: 0.9;
transform: translateY(-1px);
} }
.ant-btn-default { .ant-btn-default {
@@ -297,7 +307,7 @@ body {
.ant-select-focused .ant-select-selector, .ant-select-focused .ant-select-selector,
.ant-picker-focused { .ant-picker-focused {
border-color: var(--erp-primary) !important; border-color: var(--erp-primary) !important;
box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.12) !important; box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.12) !important;
} }
/* --- Modal --- */ /* --- Modal --- */
@@ -426,12 +436,12 @@ body {
border-radius: var(--erp-radius-lg) var(--erp-radius-lg) 0 0; border-radius: var(--erp-radius-lg) var(--erp-radius-lg) 0 0;
} }
.erp-gradient-card.indigo::before { background: linear-gradient(90deg, #4F46E5, #818CF8); } .erp-gradient-card.indigo::before { background: linear-gradient(90deg, #2563eb, #60a5fa); }
.erp-gradient-card.emerald::before { background: linear-gradient(90deg, #059669, #34D399); } .erp-gradient-card.emerald::before { background: linear-gradient(90deg, #059669, #34d399); }
.erp-gradient-card.amber::before { background: linear-gradient(90deg, #D97706, #FBBF24); } .erp-gradient-card.amber::before { background: linear-gradient(90deg, #d97706, #fbbf24); }
.erp-gradient-card.rose::before { background: linear-gradient(90deg, #E11D48, #FB7185); } .erp-gradient-card.rose::before { background: linear-gradient(90deg, #dc2626, #f87171); }
.erp-gradient-card.sky::before { background: linear-gradient(90deg, #0284C7, #38BDF8); } .erp-gradient-card.sky::before { background: linear-gradient(90deg, #0284c7, #38bdf8); }
.erp-gradient-card.violet::before { background: linear-gradient(90deg, #7C3AED, #A78BFA); } .erp-gradient-card.violet::before { background: linear-gradient(90deg, #7c3aed, #a78bfa); }
/* --- Fade-in Animation --- */ /* --- Fade-in Animation --- */
@keyframes erp-fade-in { @keyframes erp-fade-in {
@@ -465,7 +475,7 @@ body {
*:focus-visible { *:focus-visible {
outline: 2px solid var(--erp-primary); outline: 2px solid var(--erp-primary);
outline-offset: 2px; outline-offset: 2px;
border-radius: 4px; border-radius: var(--erp-radius-sm);
} }
.erp-sidebar-item:focus-visible { .erp-sidebar-item:focus-visible {
@@ -481,7 +491,7 @@ body {
background: var(--erp-primary); background: var(--erp-primary);
color: #fff; color: #fff;
padding: 8px 24px; padding: 8px 24px;
border-radius: 0 0 8px 8px; border-radius: 0 0 var(--erp-radius-md) var(--erp-radius-md);
z-index: 10000; z-index: 10000;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
@@ -529,23 +539,23 @@ body {
* ==================================================================== */ * ==================================================================== */
.erp-sidebar-menu .ant-menu-item { .erp-sidebar-menu .ant-menu-item {
margin: 2px 8px !important; margin: 1px 8px !important;
border-radius: var(--erp-radius-md) !important; border-radius: var(--erp-radius-md) !important;
height: 40px !important; height: 36px !important;
line-height: 40px !important; line-height: 36px !important;
} }
.erp-sidebar-menu .ant-menu-item-selected { .erp-sidebar-menu .ant-menu-item-selected {
background: var(--erp-primary) !important; background: #eff6ff !important;
color: #fff !important; color: #2563eb !important;
} }
.erp-sidebar-menu .ant-menu-item-selected .anticon { .erp-sidebar-menu .ant-menu-item-selected .anticon {
color: #fff !important; color: #2563eb !important;
} }
.erp-sidebar-menu .ant-menu-item:not(.ant-menu-item-selected):hover { .erp-sidebar-menu .ant-menu-item:not(.ant-menu-item-selected):hover {
background: var(--erp-bg-sidebar-hover) !important; background: #f1f5f9 !important;
} }
/* Sidebar group label */ /* Sidebar group label */
@@ -555,17 +565,17 @@ body {
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.8px; letter-spacing: 0.8px;
color: #94A3B8; color: #94a3b8;
} }
/* ==================================================================== /* ====================================================================
* MainLayout — CSS classes replacing inline styles * MainLayout — CSS classes replacing inline styles
* ==================================================================== */ * ==================================================================== */
/* Sider */ /* Sider — White sidebar, Soft UI style */
.erp-sider-dark { .erp-sider-dark {
background: #0F172A !important; background: #ffffff !important;
border-right: none !important; border-right: 1px solid #e2e8f0 !important;
position: fixed !important; position: fixed !important;
left: 0; left: 0;
top: 0; top: 0;
@@ -575,7 +585,8 @@ body {
} }
[data-theme='dark'] .erp-sider-dark { [data-theme='dark'] .erp-sider-dark {
background: #070B14 !important; background: #0f172a !important;
border-right: 1px solid #334155 !important;
} }
/* Logo */ /* Logo */
@@ -584,48 +595,56 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 20px; padding: 0 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06); border-bottom: 1px solid #e2e8f0;
cursor: pointer; cursor: pointer;
} }
[data-theme='dark'] .erp-sidebar-logo {
border-bottom: 1px solid #334155;
}
.ant-layout-sider-collapsed .erp-sidebar-logo { .ant-layout-sider-collapsed .erp-sidebar-logo {
justify-content: center; justify-content: center;
padding: 0; padding: 0;
} }
.erp-sidebar-logo-icon { .erp-sidebar-logo-icon {
width: 32px; width: 28px;
height: 32px; height: 28px;
border-radius: 8px; border-radius: var(--erp-radius-sm);
background: linear-gradient(135deg, #4F46E5, #818CF8); background: #2563eb;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-shrink: 0; flex-shrink: 0;
font-size: 14px; font-size: 13px;
font-weight: 800; font-weight: 800;
color: #fff; color: #fff;
} }
.erp-sidebar-logo-text { .erp-sidebar-logo-text {
margin-left: 12px; margin-left: 10px;
color: #F8FAFC; color: #0f172a;
font-size: 16px; font-size: 15px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.3px; letter-spacing: -0.3px;
white-space: nowrap; white-space: nowrap;
} }
[data-theme='dark'] .erp-sidebar-logo-text {
color: rgba(255, 255, 255, 0.95);
}
/* Sidebar menu item */ /* Sidebar menu item */
.erp-sidebar-item { .erp-sidebar-item {
display: flex; display: flex;
align-items: center; align-items: center;
height: 40px; height: 36px;
margin: 2px 8px; margin: 1px 8px;
padding: 0 16px; padding: 0 12px;
border-radius: 8px; border-radius: var(--erp-radius-md);
cursor: pointer; cursor: pointer;
color: #94A3B8; color: #475569;
font-size: 14px; font-size: 14px;
font-weight: 400; font-weight: 400;
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
@@ -638,14 +657,28 @@ body {
} }
.erp-sidebar-item:hover:not(.erp-sidebar-item-active) { .erp-sidebar-item:hover:not(.erp-sidebar-item-active) {
background: rgba(255, 255, 255, 0.06); background: #f1f5f9;
color: #E2E8F0; color: #0f172a;
}
[data-theme='dark'] .erp-sidebar-item {
color: #94a3b8;
}
[data-theme='dark'] .erp-sidebar-item:hover:not(.erp-sidebar-item-active) {
background: #1e293b;
color: rgba(255, 255, 255, 0.95);
} }
.erp-sidebar-item-active { .erp-sidebar-item-active {
background: linear-gradient(135deg, #4F46E5, #6366F1); background: #eff6ff;
color: #fff; color: #2563eb;
font-weight: 600; font-weight: 500;
}
[data-theme='dark'] .erp-sidebar-item-active {
background: rgba(37, 99, 235, 0.15);
color: #60a5fa;
} }
.erp-sidebar-item-icon { .erp-sidebar-item-icon {
@@ -665,10 +698,10 @@ body {
height: 32px; height: 32px;
margin: 6px 8px 2px 8px; margin: 6px 8px 2px 8px;
padding: 0 12px; padding: 0 12px;
border-radius: 6px; border-radius: var(--erp-radius-md);
cursor: pointer; cursor: pointer;
color: #94A3B8; color: #94a3b8;
font-size: 12px; font-size: 11px;
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
@@ -677,12 +710,25 @@ body {
} }
.erp-sidebar-submenu-title:hover { .erp-sidebar-submenu-title:hover {
background: rgba(255, 255, 255, 0.06); background: #f1f5f9;
color: #E2E8F0; color: #475569;
}
[data-theme='dark'] .erp-sidebar-submenu-title {
color: #64748b;
}
[data-theme='dark'] .erp-sidebar-submenu-title:hover {
background: #1e293b;
color: #94a3b8;
} }
.erp-sidebar-submenu-title-active { .erp-sidebar-submenu-title-active {
color: #A5B4FC; color: #2563eb;
}
[data-theme='dark'] .erp-sidebar-submenu-title-active {
color: #60a5fa;
} }
.erp-sidebar-submenu-arrow { .erp-sidebar-submenu-arrow {
@@ -707,8 +753,8 @@ body {
transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1); transition: margin-left 0.2s cubic-bezier(0.4, 0, 0.2, 1);
} }
.erp-main-layout-light { background: #F1F5F9; } .erp-main-layout-light { background: #f8fafc; }
.erp-main-layout-dark { background: #0B0F1A; } .erp-main-layout-dark { background: #0f172a; }
/* Header */ /* Header */
.erp-header { .erp-header {
@@ -724,44 +770,44 @@ body {
} }
.erp-header-light { .erp-header-light {
background: #FFFFFF !important; background: #ffffff !important;
border-bottom: 1px solid #F1F5F9; border-bottom: 1px solid #e2e8f0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); box-shadow: none;
} }
.erp-header-dark { .erp-header-dark {
background: #111827 !important; background: #1e293b !important;
border-bottom: 1px solid #1E293B; border-bottom: 1px solid #334155;
box-shadow: none; box-shadow: none;
} }
.erp-header-btn { .erp-header-btn {
width: 36px; width: 32px;
height: 36px; height: 32px;
border-radius: 8px; border-radius: var(--erp-radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
color: #94A3B8; color: #475569;
will-change: background; will-change: background;
} }
.erp-header-light .erp-header-btn { color: #64748B; } .erp-header-light .erp-header-btn { color: #475569; }
.erp-header-dark .erp-header-btn { color: #94A3B8; } .erp-header-dark .erp-header-btn { color: #94a3b8; }
.erp-header-btn:hover { background: #F1F5F9; } .erp-header-btn:hover { background: #f1f5f9; }
.erp-header-dark .erp-header-btn:hover { background: #1E293B; } .erp-header-dark .erp-header-btn:hover { background: #334155; }
.erp-header-title { font-size: 15px; font-weight: 600; } .erp-header-title { font-size: 15px; font-weight: 600; }
.erp-text-light { color: #0F172A; } .erp-text-light { color: #0f172a; }
.erp-text-dark { color: #F1F5F9; } .erp-text-dark { color: rgba(255, 255, 255, 0.95); }
.erp-text-light-secondary { color: #334155; } .erp-text-light-secondary { color: #475569; }
.erp-text-dark-secondary { color: #E2E8F0; } .erp-text-dark-secondary { color: #94a3b8; }
.erp-header-divider { width: 1px; height: 24px; margin: 0 8px; } .erp-header-divider { width: 1px; height: 24px; margin: 0 8px; }
.erp-header-divider-light { background: #E2E8F0; } .erp-header-divider-light { background: rgba(0, 0, 0, 0.06); }
.erp-header-divider-dark { background: #1E293B; } .erp-header-divider-dark { background: rgba(255, 255, 255, 0.06); }
/* User avatar */ /* User avatar */
.erp-header-user { .erp-header-user {
@@ -770,15 +816,15 @@ body {
gap: 10px; gap: 10px;
cursor: pointer; cursor: pointer;
padding: 4px 8px; padding: 4px 8px;
border-radius: 8px; border-radius: var(--erp-radius-sm);
transition: all 0.15s ease; transition: all 0.15s ease;
} }
.erp-header-user:hover { background: #F1F5F9; } .erp-header-user:hover { background: #f1f5f9; }
.erp-header-dark .erp-header-user:hover { background: #1E293B; } .erp-header-dark .erp-header-user:hover { background: #334155; }
.erp-user-avatar { .erp-user-avatar {
background: linear-gradient(135deg, #4F46E5, #818CF8) !important; background: #2563eb !important;
font-size: 13px !important; font-size: 13px !important;
} }
@@ -786,11 +832,11 @@ body {
/* Footer */ /* Footer */
.erp-footer { text-align: center; padding: 12px 24px !important; background: transparent !important; font-size: 12px; } .erp-footer { text-align: center; padding: 12px 24px !important; background: transparent !important; font-size: 12px; }
.erp-footer-light { color: #475569; } .erp-footer-light { color: #94a3b8; }
.erp-footer-dark { color: #94A3B8; } .erp-footer-dark { color: #64748b; }
/* ==================================================================== /* ====================================================================
* Dashboard — Stat Cards & Quick Actions (replacing inline styles) * Dashboard — Stat Cards & Quick Actions
* ==================================================================== */ * ==================================================================== */
/* Stat Card */ /* Stat Card */
@@ -818,7 +864,7 @@ body {
left: 0; left: 0;
right: 0; right: 0;
height: 3px; height: 3px;
background: var(--card-gradient, linear-gradient(135deg, #4F46E5, #6366F1)); background: var(--card-gradient, linear-gradient(135deg, #2563eb, #60a5fa));
} }
.erp-stat-card-body { .erp-stat-card-body {
@@ -849,7 +895,7 @@ body {
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: var(--erp-radius-lg); border-radius: var(--erp-radius-lg);
background: var(--card-icon-bg, rgba(79, 70, 229, 0.12)); background: var(--card-icon-bg, rgba(37, 99, 235, 0.08));
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -857,7 +903,7 @@ body {
flex-shrink: 0; flex-shrink: 0;
} }
/* Section Header (shared by dashboard sections) */ /* Section Header */
.erp-section-header { .erp-section-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -867,7 +913,7 @@ body {
.erp-section-icon { .erp-section-icon {
font-size: 16px; font-size: 16px;
color: #4F46E5; color: #2563eb;
} }
.erp-section-title { .erp-section-title {
@@ -882,7 +928,7 @@ body {
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 14px 16px; padding: 14px 16px;
border-radius: 10px; border-radius: var(--erp-radius-lg);
cursor: pointer; cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease; transition: background 0.15s ease, border-color 0.15s ease;
background: var(--erp-bg-spotlight); background: var(--erp-bg-spotlight);
@@ -890,17 +936,17 @@ body {
} }
.erp-quick-action:hover { .erp-quick-action:hover {
background: #EEF2FF; background: #eff6ff;
border-color: var(--action-color, #4F46E5); border-color: var(--action-color, #2563eb);
} }
[data-theme='dark'] .erp-quick-action { [data-theme='dark'] .erp-quick-action {
background: #0B0F1A; background: #0f172a;
} }
[data-theme='dark'] .erp-quick-action:hover { [data-theme='dark'] .erp-quick-action:hover {
background: #1E293B; background: #1e293b;
border-color: var(--action-color, #4F46E5); border-color: var(--action-color, #2563eb);
} }
.erp-quick-action-icon { .erp-quick-action-icon {
@@ -910,8 +956,8 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: color-mix(in srgb, var(--action-color, #4F46E5) 10%, transparent); background: color-mix(in srgb, var(--action-color, #2563eb) 8%, transparent);
color: var(--action-color, #4F46E5); color: var(--action-color, #2563eb);
font-size: 16px; font-size: 16px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -962,12 +1008,12 @@ body {
font-weight: 500; font-weight: 500;
} }
.erp-stat-card-trend-up { color: #047857; } .erp-stat-card-trend-up { color: #059669; }
.erp-stat-card-trend-down { color: #B91C1C; } .erp-stat-card-trend-down { color: #dc2626; }
.erp-stat-card-trend-neutral { color: #64748B; } .erp-stat-card-trend-neutral { color: #475569; }
.erp-stat-card-trend-label { .erp-stat-card-trend-label {
color: #64748B; color: #475569;
font-weight: 400; font-weight: 400;
} }
@@ -1000,8 +1046,8 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: color-mix(in srgb, var(--action-color, #4F46E5) 10%, transparent); background: color-mix(in srgb, var(--action-color, #2563eb) 8%, transparent);
color: var(--action-color, #4F46E5); color: var(--action-color, #2563eb);
font-size: 18px; font-size: 18px;
flex-shrink: 0; flex-shrink: 0;
transition: transform 0.15s ease; transition: transform 0.15s ease;
@@ -1029,7 +1075,7 @@ body {
padding: 12px 16px; padding: 12px 16px;
border-radius: var(--erp-radius-md); border-radius: var(--erp-radius-md);
background: var(--erp-bg-spotlight); background: var(--erp-bg-spotlight);
border-left: 3px solid var(--task-color, #4F46E5); border-left: 3px solid var(--task-color, #2563eb);
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
@@ -1042,12 +1088,12 @@ body {
.erp-task-item-icon { .erp-task-item-icon {
width: 32px; width: 32px;
height: 32px; height: 32px;
border-radius: 8px; border-radius: var(--erp-radius-sm);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: color-mix(in srgb, var(--task-color, #4F46E5) 12%, transparent); background: color-mix(in srgb, var(--task-color, #2563eb) 8%, transparent);
color: var(--task-color, #4F46E5); color: var(--task-color, #2563eb);
font-size: 14px; font-size: 14px;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -1069,25 +1115,25 @@ body {
gap: 12px; gap: 12px;
margin-top: 2px; margin-top: 2px;
font-size: var(--erp-font-size-xs); font-size: var(--erp-font-size-xs);
color: #64748B; color: #94a3b8;
} }
.erp-task-priority { .erp-task-priority {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
padding: 1px 8px; padding: 1px 8px;
border-radius: 10px; border-radius: var(--erp-radius-sm);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
} }
.erp-task-priority-high { background: #FEF2F2; color: #B91C1C; } .erp-task-priority-high { background: #fef2f2; color: #dc2626; }
.erp-task-priority-medium { background: #FFFBEB; color: #92400E; } .erp-task-priority-medium { background: #fffbeb; color: #d97706; }
.erp-task-priority-low { background: #ECFDF5; color: #047857; } .erp-task-priority-low { background: #ecfdf5; color: #059669; }
[data-theme='dark'] .erp-task-priority-high { background: rgba(185, 28, 28, 0.15); color: #FCA5A5; } [data-theme='dark'] .erp-task-priority-high { background: rgba(220, 38, 38, 0.15); color: #f87171; }
[data-theme='dark'] .erp-task-priority-medium { background: rgba(146, 64, 14, 0.15); color: #FCD34D; } [data-theme='dark'] .erp-task-priority-medium { background: rgba(217, 119, 6, 0.15); color: #fbbf24; }
[data-theme='dark'] .erp-task-priority-low { background: rgba(4, 120, 87, 0.15); color: #6EE7B7; } [data-theme='dark'] .erp-task-priority-low { background: rgba(5, 150, 105, 0.15); color: #34d399; }
/* Activity Timeline */ /* Activity Timeline */
.erp-activity-list { .erp-activity-list {
@@ -1143,12 +1189,12 @@ body {
.erp-activity-time { .erp-activity-time {
font-size: 11px; font-size: 11px;
color: #64748B; color: #94a3b8;
margin-top: 2px; margin-top: 2px;
} }
[data-theme='dark'] .erp-activity-time { [data-theme='dark'] .erp-activity-time {
color: #94A3B8; color: #64748b;
} }
/* Empty State */ /* Empty State */

View File

@@ -167,7 +167,7 @@ export default function Home() {
title: '用户总数', title: '用户总数',
value: stats.userCount, value: stats.userCount,
icon: <UserOutlined />, icon: <UserOutlined />,
gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)', gradient: 'linear-gradient(135deg, #2563eb, #60a5fa)',
iconBg: 'rgba(79, 70, 229, 0.12)', iconBg: 'rgba(79, 70, 229, 0.12)',
delay: 'erp-fade-in erp-fade-in-delay-1', delay: 'erp-fade-in erp-fade-in-delay-1',
trend: { value: '+2', direction: 'up', label: '较上周' }, trend: { value: '+2', direction: 'up', label: '较上周' },
@@ -191,7 +191,7 @@ export default function Home() {
title: '流程实例', title: '流程实例',
value: stats.processInstanceCount, value: stats.processInstanceCount,
icon: <FileTextOutlined />, icon: <FileTextOutlined />,
gradient: 'linear-gradient(135deg, #D97706, #F59E0B)', gradient: 'linear-gradient(135deg, #d97706, #F59E0B)',
iconBg: 'rgba(217, 119, 6, 0.12)', iconBg: 'rgba(217, 119, 6, 0.12)',
delay: 'erp-fade-in erp-fade-in-delay-3', delay: 'erp-fade-in erp-fade-in-delay-3',
trend: { value: '0', direction: 'neutral', label: '较昨日' }, trend: { value: '0', direction: 'neutral', label: '较昨日' },
@@ -213,17 +213,17 @@ export default function Home() {
]; ];
const quickActions = [ const quickActions = [
{ icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#4F46E5' }, { icon: <UserOutlined />, label: '用户管理', path: '/users', color: '#2563eb' },
{ icon: <SafetyCertificateOutlined />, label: '权限管理', path: '/roles', color: '#059669' }, { icon: <SafetyCertificateOutlined />, label: '权限管理', path: '/roles', color: '#059669' },
{ icon: <ApartmentOutlined />, label: '组织架构', path: '/organizations', color: '#D97706' }, { icon: <ApartmentOutlined />, label: '组织架构', path: '/organizations', color: '#d97706' },
{ icon: <PartitionOutlined />, label: '工作流', path: '/workflow', color: '#7C3AED' }, { icon: <PartitionOutlined />, label: '工作流', path: '/workflow', color: '#7C3AED' },
{ icon: <BellOutlined />, label: '消息中心', path: '/messages', color: '#E11D48' }, { icon: <BellOutlined />, label: '消息中心', path: '/messages', color: '#E11D48' },
{ icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#64748B' }, { icon: <SettingOutlined />, label: '系统设置', path: '/settings', color: '#475569' },
]; ];
const pendingTasks: TaskItem[] = [ const pendingTasks: TaskItem[] = [
{ id: '1', title: '审核新用户注册申请', priority: 'high', assignee: '系统', dueText: '待处理', color: '#DC2626', icon: <UserOutlined />, path: '/users' }, { 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: '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' }, { id: '3', title: '更新角色权限策略', priority: 'low', assignee: '管理员', dueText: '计划中', color: '#059669', icon: <SafetyCertificateOutlined />, path: '/roles' },
]; ];
@@ -243,13 +243,13 @@ export default function Home() {
<h2 style={{ <h2 style={{
fontSize: 24, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: isDark ? '#F1F5F9' : '#0F172A', color: isDark ? '#f8fafc' : 'rgba(0,0,0,0.95)',
margin: '0 0 4px', margin: '0 0 4px',
letterSpacing: '-0.5px', letterSpacing: '-0.5px',
}}> }}>
</h2> </h2>
<p style={{ fontSize: 14, color: isDark ? '#94A3B8' : '#475569', margin: 0 }}> <p style={{ fontSize: 14, color: isDark ? '#94a3b8' : '#475569', margin: 0 }}>
</p> </p>
</div> </div>
@@ -308,12 +308,12 @@ export default function Home() {
<Col xs={24} lg={14}> <Col xs={24} lg={14}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-2"> <div className="erp-content-card erp-fade-in erp-fade-in-delay-2">
<div className="erp-section-header"> <div className="erp-section-header">
<CheckCircleOutlined className="erp-section-icon" style={{ color: '#E11D48' }} /> <CheckCircleOutlined className="erp-section-icon" style={{ color: '#2563eb' }} />
<span className="erp-section-title"></span> <span className="erp-section-title"></span>
<span style={{ <span style={{
marginLeft: 'auto', marginLeft: 'auto',
fontSize: 12, fontSize: 12,
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
}}> }}>
{pendingTasks.length} {pendingTasks.length}
</span> </span>
@@ -351,7 +351,7 @@ export default function Home() {
<Col xs={24} lg={10}> <Col xs={24} lg={10}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-3" style={{ height: '100%' }}> <div className="erp-content-card erp-fade-in erp-fade-in-delay-3" style={{ height: '100%' }}>
<div className="erp-section-header"> <div className="erp-section-header">
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#6366F1' }} /> <ClockCircleOutlined className="erp-section-icon" style={{ color: '#60a5fa' }} />
<span className="erp-section-title"></span> <span className="erp-section-title"></span>
</div> </div>
<div className="erp-activity-list"> <div className="erp-activity-list">
@@ -400,7 +400,7 @@ export default function Home() {
<Col xs={24} lg={8}> <Col xs={24} lg={8}>
<div className="erp-content-card erp-fade-in erp-fade-in-delay-4" style={{ height: '100%' }}> <div className="erp-content-card erp-fade-in erp-fade-in-delay-4" style={{ height: '100%' }}>
<div className="erp-section-header"> <div className="erp-section-header">
<ClockCircleOutlined className="erp-section-icon" style={{ color: '#6366F1' }} /> <ClockCircleOutlined className="erp-section-icon" style={{ color: '#60a5fa' }} />
<span className="erp-section-title"></span> <span className="erp-section-title"></span>
</div> </div>
<div className="erp-system-info-list"> <div className="erp-system-info-list">

View File

@@ -30,7 +30,7 @@ export default function Login() {
<div <div
style={{ style={{
flex: 1, flex: 1,
background: 'linear-gradient(135deg, #312E81 0%, #4F46E5 50%, #6366F1 100%)', background: 'linear-gradient(135deg, #312E81 0%, #2563eb 50%, #60a5fa 100%)',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
@@ -151,7 +151,7 @@ export default function Login() {
<h2 style={{ marginBottom: 4, fontWeight: 700, fontSize: 24 }}> <h2 style={{ marginBottom: 4, fontWeight: 700, fontSize: 24 }}>
</h2> </h2>
<p style={{ fontSize: 14, color: '#64748B' }}> <p style={{ fontSize: 14, color: '#475569' }}>
</p> </p>
@@ -163,7 +163,7 @@ export default function Login() {
rules={[{ required: true, message: '请输入用户名' }]} rules={[{ required: true, message: '请输入用户名' }]}
> >
<Input <Input
prefix={<UserOutlined style={{ color: '#94A3B8' }} />} prefix={<UserOutlined style={{ color: '#94a3b8' }} />}
placeholder="用户名" placeholder="用户名"
style={{ height: 44, borderRadius: 10 }} style={{ height: 44, borderRadius: 10 }}
/> />
@@ -173,7 +173,7 @@ export default function Login() {
rules={[{ required: true, message: '请输入密码' }]} rules={[{ required: true, message: '请输入密码' }]}
> >
<Input.Password <Input.Password
prefix={<LockOutlined style={{ color: '#94A3B8' }} />} prefix={<LockOutlined style={{ color: '#94a3b8' }} />}
placeholder="密码" placeholder="密码"
style={{ height: 44, borderRadius: 10 }} style={{ height: 44, borderRadius: 10 }}
/> />
@@ -197,7 +197,7 @@ export default function Login() {
</Form> </Form>
<div style={{ marginTop: 32, textAlign: 'center' }}> <div style={{ marginTop: 32, textAlign: 'center' }}>
<p style={{ fontSize: 12, color: '#64748B', margin: 0 }}> <p style={{ fontSize: 12, color: '#475569', margin: 0 }}>
ERP Platform v0.1.0 · Powered by Rust + React ERP Platform v0.1.0 · Powered by Rust + React
</p> </p>
</div> </div>

View File

@@ -44,7 +44,7 @@ export default function Organizations() {
const cardStyle = { const cardStyle = {
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}; };
// --- Org tree state --- // --- Org tree state ---
@@ -264,9 +264,9 @@ export default function Organizations() {
{item.name}{' '} {item.name}{' '}
{item.code && <Tag style={{ {item.code && <Tag style={{
marginLeft: 4, marginLeft: 4,
background: isDark ? '#1E293B' : '#EEF2FF', background: isDark ? '#0f172a' : '#eff6ff',
border: 'none', border: 'none',
color: '#4F46E5', color: '#2563eb',
fontSize: 11, fontSize: 11,
}}>{item.code}</Tag>} }}>{item.code}</Tag>}
</span> </span>
@@ -282,7 +282,7 @@ export default function Organizations() {
{item.name}{' '} {item.name}{' '}
{item.code && <Tag style={{ {item.code && <Tag style={{
marginLeft: 4, marginLeft: 4,
background: isDark ? '#1E293B' : '#ECFDF5', background: isDark ? '#0f172a' : '#ECFDF5',
border: 'none', border: 'none',
color: '#059669', color: '#059669',
fontSize: 11, fontSize: 11,
@@ -343,7 +343,7 @@ export default function Organizations() {
<div className="erp-page-header"> <div className="erp-page-header">
<div> <div>
<h4> <h4>
<ApartmentOutlined style={{ marginRight: 8, color: '#4F46E5' }} /> <ApartmentOutlined style={{ marginRight: 8, color: '#2563eb' }} />
</h4> </h4>
<div className="erp-page-subtitle"></div> <div className="erp-page-subtitle"></div>
@@ -356,7 +356,7 @@ export default function Organizations() {
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}> <div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{ <div style={{
padding: '14px 20px', padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
@@ -418,7 +418,7 @@ export default function Organizations() {
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}> <div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{ <div style={{
padding: '14px 20px', padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',
@@ -471,7 +471,7 @@ export default function Organizations() {
<div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}> <div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}>
<div style={{ <div style={{
padding: '14px 20px', padding: '14px 20px',
borderBottom: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: 'space-between',

View File

@@ -41,11 +41,11 @@ import {
import PluginSettingsForm from '../components/PluginSettingsForm'; import PluginSettingsForm from '../components/PluginSettingsForm';
const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = { const STATUS_CONFIG: Record<PluginStatus, { color: string; label: string }> = {
uploaded: { color: '#64748B', label: '已上传' }, uploaded: { color: '#475569', label: '已上传' },
installed: { color: '#2563EB', label: '已安装' }, installed: { color: '#2563EB', label: '已安装' },
enabled: { color: '#059669', label: '已启用' }, enabled: { color: '#059669', label: '已启用' },
running: { color: '#059669', label: '运行中' }, running: { color: '#059669', label: '运行中' },
disabled: { color: '#DC2626', label: '已禁用' }, disabled: { color: '#dc2626', label: '已禁用' },
uninstalled: { color: '#9333EA', label: '已卸载' }, uninstalled: { color: '#9333EA', label: '已卸载' },
}; };
@@ -215,7 +215,7 @@ export default function PluginAdmin() {
key: 'status', key: 'status',
width: 100, width: 100,
render: (status: PluginStatus) => { render: (status: PluginStatus) => {
const cfg = STATUS_CONFIG[status] || { color: '#64748B', label: status }; const cfg = STATUS_CONFIG[status] || { color: '#475569', label: status };
return <Tag color={cfg.color}>{cfg.label}</Tag>; return <Tag color={cfg.color}>{cfg.label}</Tag>;
}, },
}, },

View File

@@ -773,9 +773,14 @@ export default function PluginCRUDPage({
name={field.name} name={field.name}
label={field.display_name || field.name} label={field.display_name || field.name}
rules={ rules={
field.required [
? [{ required: true, message: `请输入${field.display_name || field.name}` }] ...(field.required
: [] ? [{ required: true, message: `请输入${field.display_name || field.name}` }]
: []),
...(field.validation?.pattern
? [{ pattern: new RegExp(field.validation.pattern), message: field.validation.message || '格式不正确' }]
: []),
]
} }
valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'} valuePropName={field.field_type === 'boolean' ? 'checked' : 'value'}
> >

View File

@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Row, Col, Empty, Select, theme } from 'antd'; import { Row, Col, Empty, Select, theme } from 'antd';
import { DashboardOutlined } from '@ant-design/icons'; import { DashboardOutlined } from '@ant-design/icons';
import { countPluginData, aggregatePluginData } from '../api/pluginData'; import { countPluginData, aggregatePluginData, listPluginData } from '../api/pluginData';
import { import {
getPluginSchema, getPluginSchema,
type PluginEntitySchema, type PluginEntitySchema,
@@ -134,10 +134,65 @@ export function PluginDashboardPage() {
const results = await Promise.all( const results = await Promise.all(
widgets.map(async (widget) => { widgets.map(async (widget) => {
try { try {
// 旧类型
if (widget.type === 'stat_card') { if (widget.type === 'stat_card') {
const count = await countPluginData(pluginId!, widget.entity); const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count }; return { widget, data: [], count };
} }
// stat_cards — 多个统计卡片
if (widget.type === 'stat_cards' && widget.cards) {
const cardResults = await Promise.all(
widget.cards.map(async (card) => {
try {
const count = await countPluginData(pluginId!, card.entity, {
filter: card.filter ? JSON.parse(card.filter) : undefined,
});
return { card, value: count };
} catch {
return { card, value: 0 };
}
}),
);
return { widget, data: [], statCards: cardResults };
}
// action_list — 待办列表
if (widget.type === 'action_list' && widget.queries) {
const actionResults = await Promise.all(
widget.queries.map(async (query) => {
try {
const filterObj = query.filter ? JSON.parse(query.filter) : undefined;
const sortParts = query.sort?.split(' ') ?? [];
const result = await listPluginData(pluginId!, query.entity, 1, widget.max_items ?? 10, {
filter: filterObj,
sort_by: sortParts[0] || undefined,
sort_order: (sortParts[1] as 'asc' | 'desc') || undefined,
});
return { query, records: result.data };
} catch {
return { query, records: [] };
}
}),
);
return { widget, data: [], actionItems: actionResults };
}
// funnel — 阶段漏斗
if (widget.type === 'funnel' && widget.lane_field) {
const data = await aggregatePluginData(
pluginId!,
widget.entity,
widget.lane_field,
);
return { widget, data };
}
// card_list — 卡片列表
if (widget.type === 'card_list') {
const filterObj = widget.filter ? JSON.parse(widget.filter) : undefined;
const result = await listPluginData(pluginId!, widget.entity, 1, widget.max_items ?? 10, {
filter: filterObj,
});
return { widget, data: [], records: result.data };
}
// 旧类型图表
if (widget.dimension_field) { if (widget.dimension_field) {
const data = await aggregatePluginData( const data = await aggregatePluginData(
pluginId!, pluginId!,
@@ -146,7 +201,7 @@ export function PluginDashboardPage() {
); );
return { widget, data }; return { widget, data };
} }
// 没有 dimension_field 时仅返回计数 // fallback — 仅返回计数
const count = await countPluginData(pluginId!, widget.entity); const count = await countPluginData(pluginId!, widget.entity);
return { widget, data: [], count }; return { widget, data: [], count };
} catch { } catch {
@@ -244,7 +299,7 @@ export function PluginDashboardPage() {
style={{ style={{
fontSize: 24, fontSize: 24,
fontWeight: 700, fontWeight: 700,
color: isDark ? '#F1F5F9' : '#0F172A', color: isDark ? '#f8fafc' : 'rgba(0,0,0,0.95)',
margin: '0 0 4px', margin: '0 0 4px',
letterSpacing: '-0.5px', letterSpacing: '-0.5px',
}} }}
@@ -254,7 +309,7 @@ export function PluginDashboardPage() {
<p <p
style={{ style={{
fontSize: 14, fontSize: 14,
color: isDark ? '#94A3B8' : '#475569', color: isDark ? '#94a3b8' : '#475569',
margin: 0, margin: 0,
}} }}
> >
@@ -297,7 +352,7 @@ export function PluginDashboardPage() {
<div className="erp-section-header"> <div className="erp-section-header">
<DashboardOutlined <DashboardOutlined
className="erp-section-icon" className="erp-section-icon"
style={{ color: '#4F46E5' }} style={{ color: '#2563eb' }}
/> />
<span className="erp-section-title"></span> <span className="erp-section-title"></span>
</div> </div>
@@ -313,6 +368,10 @@ export function PluginDashboardPage() {
{widgetData.map((wd) => { {widgetData.map((wd) => {
const colSpan = wd.widget.type === 'stat_card' ? 6 const colSpan = wd.widget.type === 'stat_card' ? 6
: wd.widget.type === 'pie_chart' || wd.widget.type === 'funnel_chart' ? 12 : wd.widget.type === 'pie_chart' || wd.widget.type === 'funnel_chart' ? 12
: wd.widget.type === 'stat_cards' ? 24
: wd.widget.type === 'action_list' ? 12
: wd.widget.type === 'funnel' ? 12
: wd.widget.type === 'card_list' ? 12
: 12; : 12;
return ( return (
<Col key={`${wd.widget.type}-${wd.widget.entity}-${wd.widget.title}`} xs={24} sm={colSpan}> <Col key={`${wd.widget.type}-${wd.widget.entity}-${wd.widget.title}`} xs={24} sm={colSpan}>
@@ -330,7 +389,7 @@ export function PluginDashboardPage() {
<div className="erp-section-header"> <div className="erp-section-header">
<DashboardOutlined <DashboardOutlined
className="erp-section-icon" className="erp-section-icon"
style={{ color: currentPalette.tagColor === 'purple' ? '#4F46E5' : '#3B82F6' }} style={{ color: currentPalette.tagColor === 'purple' ? '#2563eb' : '#3B82F6' }}
/> />
<span className="erp-section-title"> <span className="erp-section-title">
{currentEntity?.display_name || selectedEntity} {currentEntity?.display_name || selectedEntity}

View File

@@ -313,8 +313,8 @@ export function PluginGraphPage() {
const r = degreeToRadius(degree, isCenter); const r = degreeToRadius(degree, isCenter);
// Determine node color from its most common edge type, or default palette // Determine node color from its most common edge type, or default palette
let nodeColorBase = '#4F46E5'; let nodeColorBase = '#2563eb';
let nodeColorLight = '#818CF8'; let nodeColorLight = '#60a5fa';
let nodeColorGlow = 'rgba(79,70,229,0.3)'; let nodeColorGlow = 'rgba(79,70,229,0.3)';
if (isCenter) { if (isCenter) {

View File

@@ -40,9 +40,9 @@ const CATEGORY_COLORS: Record<string, string> = {
'财务': '#059669', '财务': '#059669',
'CRM': '#2563EB', 'CRM': '#2563EB',
'进销存': '#9333EA', '进销存': '#9333EA',
'生产': '#DC2626', '生产': '#dc2626',
'人力资源': '#D97706', '人力资源': '#d97706',
'基础': '#64748B', '基础': '#475569',
}; };
export default function PluginMarket() { export default function PluginMarket() {
@@ -190,7 +190,7 @@ export default function PluginMarket() {
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<Text strong style={{ fontSize: 16 }}>{plugin.name}</Text> <Text strong style={{ fontSize: 16 }}>{plugin.name}</Text>
<Tag <Tag
color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#64748B'} color={CATEGORY_COLORS[plugin.category ?? ''] ?? '#475569'}
style={{ marginLeft: 8 }} style={{ marginLeft: 8 }}
> >
{plugin.category} {plugin.category}
@@ -244,7 +244,7 @@ export default function PluginMarket() {
<div> <div>
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<Space> <Space>
<Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#64748B'}> <Tag color={CATEGORY_COLORS[selectedPlugin.category ?? ''] ?? '#475569'}>
{selectedPlugin.category} {selectedPlugin.category}
</Tag> </Tag>
<Text type="secondary">v{selectedPlugin.version}</Text> <Text type="secondary">v{selectedPlugin.version}</Text>

View File

@@ -153,12 +153,12 @@ export default function Roles() {
height: 32, height: 32,
borderRadius: 8, borderRadius: 8,
background: record.is_system background: record.is_system
? 'linear-gradient(135deg, #4F46E5, #818CF8)' ? 'linear-gradient(135deg, #2563eb, #60a5fa)'
: isDark ? '#1E293B' : '#F1F5F9', : isDark ? '#0f172a' : '#f8fafc',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
color: record.is_system ? '#fff' : isDark ? '#94A3B8' : '#64748B', color: record.is_system ? '#fff' : isDark ? '#94a3b8' : '#475569',
fontSize: 14, fontSize: 14,
}} }}
> >
@@ -174,9 +174,9 @@ export default function Roles() {
key: 'code', key: 'code',
render: (v: string) => ( render: (v: string) => (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 12, fontSize: 12,
}}> }}>
@@ -190,7 +190,7 @@ export default function Roles() {
key: 'description', key: 'description',
ellipsis: true, ellipsis: true,
render: (v: string | undefined) => ( render: (v: string | undefined) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8' }}>{v || '-'}</span> <span style={{ color: isDark ? '#475569' : '#94a3b8' }}>{v || '-'}</span>
), ),
}, },
{ {
@@ -201,8 +201,8 @@ export default function Roles() {
render: (v: boolean) => ( render: (v: boolean) => (
<Tag <Tag
style={{ style={{
color: v ? '#4F46E5' : (isDark ? '#94A3B8' : '#64748B'), color: v ? '#2563eb' : (isDark ? '#94a3b8' : '#475569'),
background: v ? '#EEF2FF' : (isDark ? '#1E293B' : '#F1F5F9'), background: v ? '#eff6ff' : (isDark ? '#0f172a' : '#f8fafc'),
border: 'none', border: 'none',
fontWeight: 500, fontWeight: 500,
}} }}
@@ -222,7 +222,7 @@ export default function Roles() {
type="text" type="text"
icon={<SafetyCertificateOutlined />} icon={<SafetyCertificateOutlined />}
onClick={() => openPermModal(record)} onClick={() => openPermModal(record)}
style={{ color: '#4F46E5' }} style={{ color: '#2563eb' }}
> >
</Button> </Button>
@@ -233,7 +233,7 @@ export default function Roles() {
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => openEditModal(record)} onClick={() => openEditModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }} style={{ color: isDark ? '#94a3b8' : '#475569' }}
/> />
<Popconfirm <Popconfirm
title="确定删除此角色?" title="确定删除此角色?"
@@ -279,7 +279,7 @@ export default function Roles() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table
@@ -336,8 +336,8 @@ export default function Roles() {
marginBottom: 16, marginBottom: 16,
padding: 16, padding: 16,
borderRadius: 10, borderRadius: 10,
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`, border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#F8FAFC', background: isDark ? '#0B0F1A' : '#f1f5f9',
}} }}
> >
<div style={{ <div style={{

View File

@@ -36,8 +36,8 @@ import type { UserInfo } from '../api/auth';
const STATUS_COLOR_MAP: Record<string, string> = { const STATUS_COLOR_MAP: Record<string, string> = {
active: '#059669', active: '#059669',
disabled: '#DC2626', disabled: '#dc2626',
locked: '#D97706', locked: '#d97706',
}; };
const STATUS_BG_MAP: Record<string, string> = { const STATUS_BG_MAP: Record<string, string> = {
@@ -219,7 +219,7 @@ export default function Users() {
width: 32, width: 32,
height: 32, height: 32,
borderRadius: 8, borderRadius: 8,
background: 'linear-gradient(135deg, #4F46E5, #818CF8)', background: 'linear-gradient(135deg, #2563eb, #60a5fa)',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@@ -233,7 +233,7 @@ export default function Users() {
<div> <div>
<div style={{ fontWeight: 500, fontSize: 14 }}>{v}</div> <div style={{ fontWeight: 500, fontSize: 14 }}>{v}</div>
{record.display_name && ( {record.display_name && (
<div style={{ fontSize: 12, color: isDark ? '#64748B' : '#94A3B8' }}> <div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8' }}>
{record.display_name} {record.display_name}
</div> </div>
)} )}
@@ -261,8 +261,8 @@ export default function Users() {
render: (status: string) => ( render: (status: string) => (
<Tag <Tag
style={{ style={{
color: STATUS_COLOR_MAP[status] || '#64748B', color: STATUS_COLOR_MAP[status] || '#62625b',
background: STATUS_BG_MAP[status] || '#F1F5F9', background: STATUS_BG_MAP[status] || '#f8fafc',
border: 'none', border: 'none',
fontWeight: 500, fontWeight: 500,
}} }}
@@ -279,7 +279,7 @@ export default function Users() {
roles.length > 0 roles.length > 0
? roles.map((r) => ( ? roles.map((r) => (
<Tag key={r.id} style={{ <Tag key={r.id} style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#CBD5E1' : '#475569', color: isDark ? '#CBD5E1' : '#475569',
}}> }}>
@@ -299,14 +299,14 @@ export default function Users() {
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => openEditModal(record)} onClick={() => openEditModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }} style={{ color: isDark ? '#94a3b8' : '#475569' }}
/> />
<Button <Button
size="small" size="small"
type="text" type="text"
icon={<SafetyCertificateOutlined />} icon={<SafetyCertificateOutlined />}
onClick={() => openRoleModal(record)} onClick={() => openRoleModal(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }} style={{ color: isDark ? '#94a3b8' : '#475569' }}
/> />
{record.status === 'active' ? ( {record.status === 'active' ? (
<Popconfirm <Popconfirm
@@ -356,7 +356,7 @@ export default function Users() {
<Space size={8}> <Space size={8}>
<Input <Input
placeholder="搜索用户名..." placeholder="搜索用户名..."
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />} prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={searchText} value={searchText}
onChange={(e) => { onChange={(e) => {
setSearchText(e.target.value); setSearchText(e.target.value);
@@ -379,7 +379,7 @@ export default function Users() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table
@@ -415,7 +415,7 @@ export default function Users() {
label="用户名" label="用户名"
rules={[{ required: true, message: '请输入用户名' }]} rules={[{ required: true, message: '请输入用户名' }]}
> >
<Input prefix={<UserOutlined style={{ color: '#94A3B8' }} />} disabled={!!editUser} /> <Input prefix={<UserOutlined style={{ color: '#94a3b8' }} />} disabled={!!editUser} />
</Form.Item> </Form.Item>
{!editUser && ( {!editUser && (
<Form.Item <Form.Item
@@ -465,13 +465,13 @@ export default function Users() {
style={{ style={{
padding: '10px 14px', padding: '10px 14px',
borderRadius: 8, borderRadius: 8,
border: `1px solid ${isDark ? '#1E293B' : '#E2E8F0'}`, border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#F8FAFC', background: isDark ? '#0B0F1A' : '#f1f5f9',
}} }}
> >
<Checkbox value={r.id}> <Checkbox value={r.id}>
<span style={{ fontWeight: 500 }}>{r.name}</span> <span style={{ fontWeight: 500 }}>{r.name}</span>
<span style={{ color: isDark ? '#475569' : '#94A3B8', marginLeft: 8, fontSize: 12 }}> <span style={{ color: isDark ? '#475569' : '#94a3b8', marginLeft: 8, fontSize: 12 }}>
{r.code} {r.code}
</span> </span>
</Checkbox> </Checkbox>

View File

@@ -1,11 +1,13 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { Col, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography } from 'antd'; import { Col, Row, Spin, Empty, Tag, Progress, Skeleton, Tooltip, Card, Typography, List, Badge } from 'antd';
import { import {
InfoCircleOutlined, InfoCircleOutlined,
DashboardOutlined, DashboardOutlined,
RightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Column, Pie, Funnel, Line } from '@ant-design/charts'; import { Column, Pie, Funnel, Line } from '@ant-design/charts';
import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes'; import type { EntityStat, FieldBreakdown, WidgetData } from './dashboardTypes';
import type { ActionQueryDef } from '../../api/plugins';
import { TAG_COLORS, WIDGET_ICON_MAP } from './dashboardConstants'; import { TAG_COLORS, WIDGET_ICON_MAP } from './dashboardConstants';
// ── 计数动画 Hook ── // ── 计数动画 Hook ──
@@ -44,7 +46,7 @@ function prepareChartData(data: WidgetData['data'], dimensionOrder?: string[]) {
const TAG_COLOR_MAP: Record<string, string> = { const TAG_COLOR_MAP: Record<string, string> = {
blue: '#3B82F6', green: '#10B981', orange: '#F59E0B', red: '#EF4444', blue: '#3B82F6', green: '#10B981', orange: '#F59E0B', red: '#EF4444',
purple: '#8B5CF6', cyan: '#06B6D4', magenta: '#EC4899', gold: '#EAB308', purple: '#8B5CF6', cyan: '#06B6D4', magenta: '#EC4899', gold: '#EAB308',
lime: '#84CC16', geekblue: '#6366F1', volcano: '#F97316', lime: '#84CC16', geekblue: '#60a5fa', volcano: '#F97316',
}; };
function tagStrokeColor(color: string): string { function tagStrokeColor(color: string): string {
@@ -202,7 +204,7 @@ export function SkeletonBreakdownCard({ index }: { index: number }) {
function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) { function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
const { widget, count } = widgetData; const { widget, count } = widgetData;
const animatedValue = useCountUp(count ?? 0); const animatedValue = useCountUp(count ?? 0);
const color = widget.color || '#4F46E5'; const color = widget.color || '#2563eb';
return ( return (
<Card size="small" className="erp-fade-in" style={{ height: '100%' }}> <Card size="small" className="erp-fade-in" style={{ height: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
@@ -227,7 +229,7 @@ function StatWidgetCard({ widgetData }: { widgetData: WidgetData }) {
function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) { function BarWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
const { widget, data } = widgetData; const { widget, data } = widgetData;
const chartData = prepareChartData(data, widget.dimension_order); const chartData = prepareChartData(data, widget.dimension_order);
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' }; const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
return ( return (
<WidgetCardShell title={widget.title} widgetType={widget.type}> <WidgetCardShell title={widget.title} widgetType={widget.type}>
{chartData.length > 0 ? ( {chartData.length > 0 ? (
@@ -273,7 +275,7 @@ function FunnelWidgetCard({ widgetData }: { widgetData: WidgetData }) {
function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) { function LineWidgetCard({ widgetData, isDark }: { widgetData: WidgetData; isDark: boolean }) {
const { widget, data } = widgetData; const { widget, data } = widgetData;
const chartData = prepareChartData(data, widget.dimension_order); const chartData = prepareChartData(data, widget.dimension_order);
const axisLabelStyle = { fill: isDark ? '#94A3B8' : '#475569' }; const axisLabelStyle = { fill: isDark ? '#94a3b8' : '#475569' };
return ( return (
<WidgetCardShell title={widget.title} widgetType={widget.type}> <WidgetCardShell title={widget.title} widgetType={widget.type}>
{chartData.length > 0 ? ( {chartData.length > 0 ? (
@@ -293,6 +295,146 @@ export function WidgetRenderer({ widgetData, isDark }: { widgetData: WidgetData;
case 'pie_chart': return <PieWidgetCard widgetData={widgetData} />; case 'pie_chart': return <PieWidgetCard widgetData={widgetData} />;
case 'funnel_chart': return <FunnelWidgetCard widgetData={widgetData} />; case 'funnel_chart': return <FunnelWidgetCard widgetData={widgetData} />;
case 'line_chart': return <LineWidgetCard widgetData={widgetData} isDark={isDark} />; case 'line_chart': return <LineWidgetCard widgetData={widgetData} isDark={isDark} />;
case 'stat_cards': return <StatCardsWidget widgetData={widgetData} />;
case 'action_list': return <ActionListWidget widgetData={widgetData} />;
case 'funnel': return <FunnelStageWidget widgetData={widgetData} />;
case 'card_list': return <CardListWidget widgetData={widgetData} />;
default: return null; default: return null;
} }
} }
// ── Manifest Widget 渲染器 ──
/** stat_cards — 多个统计卡片 */
function StatCardsWidget({ widgetData }: { widgetData: WidgetData }) {
const { statCards, widget } = widgetData;
if (!statCards || statCards.length === 0) return <ChartEmpty />;
return (
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.stat_cards} {widget.label || widget.title}</span>} className="erp-fade-in">
<Row gutter={[12, 12]}>
{statCards.map((sc, i) => (
<Col xs={12} sm={6} key={`${sc.card.entity}-${sc.card.label}-${i}`}>
<div style={{
background: `${sc.card.color || '#2563eb'}10`,
borderRadius: 8,
padding: '12px 16px',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<div style={{
width: 36, height: 36, borderRadius: 8,
background: `${sc.card.color || '#2563eb'}20`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: sc.card.color || '#2563eb', fontSize: 18,
}}>
<DashboardOutlined />
</div>
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{sc.card.label}</Typography.Text>
<div style={{ fontSize: 20, fontWeight: 700, fontVariantNumeric: 'tabular-nums' }}>
{sc.value.toLocaleString()}
</div>
</div>
</div>
</Col>
))}
</Row>
</Card>
);
}
/** action_list — 待办列表 */
function ActionListWidget({ widgetData }: { widgetData: WidgetData }) {
const { actionItems, widget } = widgetData;
if (!actionItems) return <ChartEmpty />;
const allItems = actionItems.flatMap((ai) =>
ai.records.map((r) => ({ ...r, _query: ai.query })),
);
const maxItems = widget.max_items ?? 10;
const displayItems = allItems.slice(0, maxItems);
return (
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.action_list} {widget.label || widget.title}</span>} className="erp-fade-in">
{displayItems.length > 0 ? (
<List
size="small"
dataSource={displayItems}
renderItem={(item) => {
const q = item._query as ActionQueryDef;
const title = String(item.data?.[q.label_field] ?? '-');
const subtitle = q.subtitle_field ? String(item.data?.[q.subtitle_field] ?? '') : '';
return (
<List.Item style={{ padding: '8px 0', cursor: 'pointer' }}>
<div style={{ display: 'flex', alignItems: 'center', width: '100%', gap: 8 }}>
<Badge color="blue" />
<div style={{ flex: 1, overflow: 'hidden' }}>
<div style={{ fontSize: 13, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{title}</div>
{subtitle && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{subtitle}</Typography.Text>}
</div>
<RightOutlined style={{ fontSize: 12, color: 'var(--erp-text-quaternary)' }} />
</div>
</List.Item>
);
}}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无待办" />
)}
</Card>
);
}
/** funnel — 阶段漏斗 */
function FunnelStageWidget({ widgetData }: { widgetData: WidgetData }) {
const { data, widget } = widgetData;
const chartData = (widget.lane_order ?? [])
.map((key) => {
const found = data.find((d) => d.key === key);
return { key, count: found?.count ?? 0 };
})
.filter((d) => d.count > 0);
return (
<WidgetCardShell title={widget.label || widget.title} widgetType="funnel_chart">
{chartData.length > 0 ? (
<Funnel data={chartData} xField="key" yField="count" legend={{ position: 'bottom' as const }} />
) : <ChartEmpty />}
</WidgetCardShell>
);
}
/** card_list — 卡片列表 */
function CardListWidget({ widgetData }: { widgetData: WidgetData }) {
const { records, widget } = widgetData;
const maxItems = widget.max_items ?? 10;
const displayRecords = (records ?? []).slice(0, maxItems);
return (
<Card size="small" title={<span style={{ fontSize: 14 }}>{WIDGET_ICON_MAP.card_list} {widget.label || widget.title}</span>} className="erp-fade-in">
{displayRecords.length > 0 ? (
<List
size="small"
dataSource={displayRecords}
renderItem={(item) => {
const title = String(item.data?.[widget.title_field ?? 'name'] ?? '-');
const subtitle = widget.subtitle_field ? String(item.data?.[widget.subtitle_field] ?? '') : '';
const tagValues = (widget.tags ?? []).map((t) => String(item.data?.[t] ?? '')).filter(Boolean);
return (
<List.Item style={{ padding: '8px 0' }}>
<div style={{ width: '100%' }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{title}</div>
{subtitle && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{subtitle}</Typography.Text>}
{tagValues.length > 0 && (
<div style={{ marginTop: 4 }}>
{tagValues.map((tv, i) => <Tag key={i} style={{ fontSize: 11 }}>{tv}</Tag>)}
</div>
)}
</div>
</List.Item>
);
}}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无数据" />
)}
</Card>
);
}

View File

@@ -19,9 +19,9 @@ import {
// ── 通用调色板 ── // ── 通用调色板 ──
const UNIVERSAL_COLORS = [ const UNIVERSAL_COLORS = [
{ gradient: 'linear-gradient(135deg, #4F46E5, #6366F1)', iconBg: 'rgba(79, 70, 229, 0.12)', tagColor: 'purple' }, { gradient: 'linear-gradient(135deg, #2563eb, #60a5fa)', iconBg: 'rgba(79, 70, 229, 0.12)', tagColor: 'purple' },
{ gradient: 'linear-gradient(135deg, #059669, #10B981)', iconBg: 'rgba(5, 150, 105, 0.12)', tagColor: 'green' }, { gradient: 'linear-gradient(135deg, #059669, #10B981)', iconBg: 'rgba(5, 150, 105, 0.12)', tagColor: 'green' },
{ gradient: 'linear-gradient(135deg, #D97706, #F59E0B)', iconBg: 'rgba(217, 119, 6, 0.12)', tagColor: 'orange' }, { gradient: 'linear-gradient(135deg, #d97706, #F59E0B)', iconBg: 'rgba(217, 119, 6, 0.12)', tagColor: 'orange' },
{ gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)', iconBg: 'rgba(124, 58, 237, 0.12)', tagColor: 'volcano' }, { gradient: 'linear-gradient(135deg, #7C3AED, #A78BFA)', iconBg: 'rgba(124, 58, 237, 0.12)', tagColor: 'volcano' },
{ gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)', iconBg: 'rgba(225, 29, 72, 0.12)', tagColor: 'red' }, { gradient: 'linear-gradient(135deg, #E11D48, #F43F5E)', iconBg: 'rgba(225, 29, 72, 0.12)', tagColor: 'red' },
{ gradient: 'linear-gradient(135deg, #0891B2, #06B6D4)', iconBg: 'rgba(8, 145, 178, 0.12)', tagColor: 'cyan' }, { gradient: 'linear-gradient(135deg, #0891B2, #06B6D4)', iconBg: 'rgba(8, 145, 178, 0.12)', tagColor: 'cyan' },
@@ -82,6 +82,10 @@ export const WIDGET_ICON_MAP: Record<string, React.ReactNode> = {
pie_chart: <PieChartOutlined />, pie_chart: <PieChartOutlined />,
funnel_chart: <FunnelPlotOutlined />, funnel_chart: <FunnelPlotOutlined />,
line_chart: <LineChartOutlined />, line_chart: <LineChartOutlined />,
stat_cards: <DashboardOutlined />,
action_list: <AppstoreOutlined />,
funnel: <FunnelPlotOutlined />,
card_list: <DatabaseOutlined />,
}; };
// ── 延迟类名工具 ── // ── 延迟类名工具 ──

View File

@@ -1,6 +1,6 @@
import type React from 'react'; import type React from 'react';
import type { AggregateItem } from '../../api/pluginData'; import type { AggregateItem, PluginDataRecord } from '../../api/pluginData';
import type { DashboardWidget } from '../../api/plugins'; import type { DashboardWidget, StatCardDef, ActionQueryDef } from '../../api/plugins';
// ── 类型定义 ── // ── 类型定义 ──
@@ -23,4 +23,7 @@ export interface WidgetData {
widget: DashboardWidget; widget: DashboardWidget;
data: AggregateItem[]; data: AggregateItem[];
count?: number; count?: number;
records?: PluginDataRecord[];
statCards?: { card: StatCardDef; value: number }[];
actionItems?: { query: ActionQueryDef; records: PluginDataRecord[] }[];
} }

View File

@@ -11,11 +11,11 @@ import type { GraphEdge } from './graphTypes';
/** 关系类型对应的色板 (base / light / glow) — 通用调色板自动分配 */ /** 关系类型对应的色板 (base / light / glow) — 通用调色板自动分配 */
const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [ const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [
{ base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' }, { base: '#2563eb', light: '#60a5fa', glow: 'rgba(79,70,229,0.3)' },
{ base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' }, { base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
{ base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' }, { base: '#d97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
{ base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' }, { base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
{ base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' }, { base: '#dc2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
{ base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' }, { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' },
{ base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' }, { base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' },
{ base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' }, { base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' },

View File

@@ -5,9 +5,9 @@ import type { ColumnsType } from 'antd/es/table';
import { listTemplates, createTemplate, type MessageTemplateInfo } from '../../api/messageTemplates'; import { listTemplates, createTemplate, type MessageTemplateInfo } from '../../api/messageTemplates';
const channelMap: Record<string, { label: string; color: string }> = { const channelMap: Record<string, { label: string; color: string }> = {
in_app: { label: '站内', color: '#4F46E5' }, in_app: { label: '站内', color: '#2563eb' },
email: { label: '邮件', color: '#059669' }, email: { label: '邮件', color: '#059669' },
sms: { label: '短信', color: '#D97706' }, sms: { label: '短信', color: '#d97706' },
wechat: { label: '微信', color: '#7C3AED' }, wechat: { label: '微信', color: '#7C3AED' },
}; };
@@ -64,9 +64,9 @@ export default function MessageTemplates() {
key: 'code', key: 'code',
render: (v: string) => ( render: (v: string) => (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 12, fontSize: 12,
}}> }}>
@@ -80,7 +80,7 @@ export default function MessageTemplates() {
key: 'channel', key: 'channel',
width: 90, width: 90,
render: (c: string) => { render: (c: string) => {
const info = channelMap[c] || { label: c, color: '#64748B' }; const info = channelMap[c] || { label: c, color: '#475569' };
return ( return (
<Tag style={{ <Tag style={{
background: info.color + '15', background: info.color + '15',
@@ -111,7 +111,7 @@ export default function MessageTemplates() {
key: 'created_at', key: 'created_at',
width: 180, width: 180,
render: (v: string) => ( render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>{v}</span> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>{v}</span>
), ),
}, },
]; ];
@@ -124,7 +124,7 @@ export default function MessageTemplates() {
alignItems: 'center', alignItems: 'center',
marginBottom: 16, marginBottom: 16,
}}> }}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}> <span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
{total} {total}
</span> </span>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}> <Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
@@ -135,7 +135,7 @@ export default function MessageTemplates() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -11,9 +11,9 @@ interface Props {
} }
const priorityStyles: Record<string, { bg: string; color: string; text: string }> = { const priorityStyles: Record<string, { bg: string; color: string; text: string }> = {
urgent: { bg: '#FEF2F2', color: '#DC2626', text: '紧急' }, urgent: { bg: '#FEF2F2', color: '#dc2626', text: '紧急' },
important: { bg: '#FFFBEB', color: '#D97706', text: '重要' }, important: { bg: '#FFFBEB', color: '#d97706', text: '重要' },
normal: { bg: '#EEF2FF', color: '#4F46E5', text: '普通' }, normal: { bg: '#eff6ff', color: '#2563eb', text: '普通' },
}; };
export default function NotificationList({ queryFilter }: Props) { export default function NotificationList({ queryFilter }: Props) {
@@ -83,7 +83,7 @@ export default function NotificationList({ queryFilter }: Props) {
content: ( content: (
<div> <div>
<Paragraph>{record.body}</Paragraph> <Paragraph>{record.body}</Paragraph>
<div style={{ marginTop: 8, color: isDark ? '#475569' : '#94A3B8', fontSize: 12 }}> <div style={{ marginTop: 8, color: isDark ? '#475569' : '#94a3b8', fontSize: 12 }}>
{record.created_at} {record.created_at}
</div> </div>
</div> </div>
@@ -104,7 +104,7 @@ export default function NotificationList({ queryFilter }: Props) {
style={{ style={{
fontWeight: record.is_read ? 400 : 600, fontWeight: record.is_read ? 400 : 600,
cursor: 'pointer', cursor: 'pointer',
color: record.is_read ? (isDark ? '#94A3B8' : '#64748B') : 'inherit', color: record.is_read ? (isDark ? '#94a3b8' : '#475569') : 'inherit',
}} }}
onClick={() => showDetail(record)} onClick={() => showDetail(record)}
> >
@@ -114,7 +114,7 @@ export default function NotificationList({ queryFilter }: Props) {
width: 6, width: 6,
height: 6, height: 6,
borderRadius: '50%', borderRadius: '50%',
background: '#4F46E5', background: '#2563eb',
marginRight: 8, marginRight: 8,
}} /> }} />
)} )}
@@ -128,7 +128,7 @@ export default function NotificationList({ queryFilter }: Props) {
key: 'priority', key: 'priority',
width: 90, width: 90,
render: (p: string) => { render: (p: string) => {
const info = priorityStyles[p] || { bg: '#F1F5F9', color: '#64748B', text: p }; const info = priorityStyles[p] || { bg: '#f8fafc', color: '#475569', text: p };
return ( return (
<Tag style={{ <Tag style={{
background: info.bg, background: info.bg,
@@ -146,7 +146,7 @@ export default function NotificationList({ queryFilter }: Props) {
dataIndex: 'sender_type', dataIndex: 'sender_type',
key: 'sender_type', key: 'sender_type',
width: 80, width: 80,
render: (s: string) => <span style={{ color: isDark ? '#64748B' : '#94A3B8' }}>{s === 'system' ? '系统' : '用户'}</span>, render: (s: string) => <span style={{ color: isDark ? '#475569' : '#94a3b8' }}>{s === 'system' ? '系统' : '用户'}</span>,
}, },
{ {
title: '状态', title: '状态',
@@ -155,9 +155,9 @@ export default function NotificationList({ queryFilter }: Props) {
width: 80, width: 80,
render: (r: boolean) => ( render: (r: boolean) => (
<Tag style={{ <Tag style={{
background: r ? (isDark ? '#1E293B' : '#F1F5F9') : '#EEF2FF', background: r ? (isDark ? '#0f172a' : '#f8fafc') : '#eff6ff',
border: 'none', border: 'none',
color: r ? (isDark ? '#64748B' : '#94A3B8') : '#4F46E5', color: r ? (isDark ? '#475569' : '#94a3b8') : '#2563eb',
fontWeight: 500, fontWeight: 500,
}}> }}>
{r ? '已读' : '未读'} {r ? '已读' : '未读'}
@@ -170,7 +170,7 @@ export default function NotificationList({ queryFilter }: Props) {
key: 'created_at', key: 'created_at',
width: 180, width: 180,
render: (v: string) => ( render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>{v}</span> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>{v}</span>
), ),
}, },
{ {
@@ -185,7 +185,7 @@ export default function NotificationList({ queryFilter }: Props) {
size="small" size="small"
icon={<CheckOutlined />} icon={<CheckOutlined />}
onClick={() => handleMarkRead(record.id)} onClick={() => handleMarkRead(record.id)}
style={{ color: '#4F46E5' }} style={{ color: '#2563eb' }}
/> />
)} )}
<Button <Button
@@ -193,7 +193,7 @@ export default function NotificationList({ queryFilter }: Props) {
size="small" size="small"
icon={<EyeOutlined />} icon={<EyeOutlined />}
onClick={() => showDetail(record)} onClick={() => showDetail(record)}
style={{ color: isDark ? '#64748B' : '#94A3B8' }} style={{ color: isDark ? '#475569' : '#94a3b8' }}
/> />
<Button <Button
type="text" type="text"
@@ -215,7 +215,7 @@ export default function NotificationList({ queryFilter }: Props) {
alignItems: 'center', alignItems: 'center',
marginBottom: 16, marginBottom: 16,
}}> }}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}> <span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
{total} {total}
</span> </span>
<Button icon={<CheckOutlined />} onClick={handleMarkAllRead}> <Button icon={<CheckOutlined />} onClick={handleMarkAllRead}>
@@ -226,7 +226,7 @@ export default function NotificationList({ queryFilter }: Props) {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -48,12 +48,12 @@ export default function NotificationPreferences() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
padding: 24, padding: 24,
maxWidth: 600, maxWidth: 600,
}}> }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 20 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 20 }}>
<BellOutlined style={{ fontSize: 16, color: '#4F46E5' }} /> <BellOutlined style={{ fontSize: 16, color: '#2563eb' }} />
<span style={{ fontSize: 15, fontWeight: 600 }}></span> <span style={{ fontSize: 15, fontWeight: 600 }}></span>
</div> </div>

View File

@@ -5,11 +5,11 @@
// 通用边调色板 // 通用边调色板
const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [ const EDGE_PALETTE: Array<{ base: string; light: string; glow: string }> = [
{ base: '#4F46E5', light: '#818CF8', glow: 'rgba(79,70,229,0.3)' }, { base: '#2563eb', light: '#60a5fa', glow: 'rgba(79,70,229,0.3)' },
{ base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' }, { base: '#059669', light: '#34D399', glow: 'rgba(5,150,105,0.3)' },
{ base: '#D97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' }, { base: '#d97706', light: '#FBBF24', glow: 'rgba(217,119,6,0.3)' },
{ base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' }, { base: '#0891B2', light: '#22D3EE', glow: 'rgba(8,145,178,0.3)' },
{ base: '#DC2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' }, { base: '#dc2626', light: '#F87171', glow: 'rgba(220,38,38,0.3)' },
{ base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' }, { base: '#7C3AED', light: '#A78BFA', glow: 'rgba(124,58,237,0.3)' },
{ base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' }, { base: '#EA580C', light: '#FB923C', glow: 'rgba(234,88,12,0.3)' },
{ base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' }, { base: '#DB2777', light: '#F472B6', glow: 'rgba(219,39,119,0.3)' },

View File

@@ -295,8 +295,8 @@ export function drawFullGraph(
const degree = degreeMap.get(node.id) || 0; const degree = degreeMap.get(node.id) || 0;
const r = degreeToRadius(degree, isCenter); const r = degreeToRadius(degree, isCenter);
let nodeColorBase = '#4F46E5'; let nodeColorBase = '#2563eb';
let nodeColorLight = '#818CF8'; let nodeColorLight = '#60a5fa';
let nodeColorGlow = 'rgba(79,70,229,0.3)'; let nodeColorGlow = 'rgba(79,70,229,0.3)';
if (isCenter) { if (isCenter) {

View File

@@ -18,8 +18,8 @@ const RESOURCE_TYPE_OPTIONS = [
const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = { const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = {
create: { bg: '#ECFDF5', color: '#059669', text: '创建' }, create: { bg: '#ECFDF5', color: '#059669', text: '创建' },
update: { bg: '#EEF2FF', color: '#4F46E5', text: '更新' }, update: { bg: '#eff6ff', color: '#2563eb', text: '更新' },
delete: { bg: '#FEF2F2', color: '#DC2626', text: '删除' }, delete: { bg: '#FEF2F2', color: '#dc2626', text: '删除' },
}; };
function formatDateTime(value: string): string { function formatDateTime(value: string): string {
@@ -80,7 +80,7 @@ export default function AuditLogViewer() {
key: 'action', key: 'action',
width: 100, width: 100,
render: (action: string) => { render: (action: string) => {
const info = ACTION_STYLES[action] || { bg: '#F1F5F9', color: '#64748B', text: action }; const info = ACTION_STYLES[action] || { bg: '#f8fafc', color: '#475569', text: action };
return ( return (
<Tag style={{ <Tag style={{
background: info.bg, background: info.bg,
@@ -100,7 +100,7 @@ export default function AuditLogViewer() {
width: 120, width: 120,
render: (v: string) => ( render: (v: string) => (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#CBD5E1' : '#475569', color: isDark ? '#CBD5E1' : '#475569',
}}> }}>
@@ -115,7 +115,7 @@ export default function AuditLogViewer() {
width: 200, width: 200,
ellipsis: true, ellipsis: true,
render: (v: string) => ( render: (v: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}> <span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>
{v} {v}
</span> </span>
), ),
@@ -127,7 +127,7 @@ export default function AuditLogViewer() {
width: 200, width: 200,
ellipsis: true, ellipsis: true,
render: (v: string) => ( render: (v: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94A3B8' : '#64748B' }}> <span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>
{v} {v}
</span> </span>
), ),
@@ -138,7 +138,7 @@ export default function AuditLogViewer() {
key: 'created_at', key: 'created_at',
width: 180, width: 180,
render: (value: string) => ( render: (value: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{formatDateTime(value)} {formatDateTime(value)}
</span> </span>
), ),
@@ -156,7 +156,7 @@ export default function AuditLogViewer() {
padding: 12, padding: 12,
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 10, borderRadius: 10,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}> }}>
<Select <Select
allowClear allowClear
@@ -173,7 +173,7 @@ export default function AuditLogViewer() {
value={query.user_id ?? ''} value={query.user_id ?? ''}
onChange={(e) => handleFilterChange('user_id', e.target.value)} onChange={(e) => handleFilterChange('user_id', e.target.value)}
/> />
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8', marginLeft: 'auto' }}> <span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8', marginLeft: 'auto' }}>
{total} {total}
</span> </span>
</div> </div>
@@ -182,7 +182,7 @@ export default function AuditLogViewer() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -132,7 +132,7 @@ export default function SystemSettings() {
width: 250, width: 250,
render: (v: string) => ( render: (v: string) => (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#CBD5E1' : '#475569', color: isDark ? '#CBD5E1' : '#475569',
fontFamily: 'monospace', fontFamily: 'monospace',
@@ -162,7 +162,7 @@ export default function SystemSettings() {
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => openEdit(record)} onClick={() => openEdit(record)}
style={{ color: isDark ? '#94A3B8' : '#64748B' }} style={{ color: isDark ? '#94a3b8' : '#475569' }}
/> />
<Popconfirm <Popconfirm
title="确定删除此设置?" title="确定删除此设置?"
@@ -191,7 +191,7 @@ export default function SystemSettings() {
<Space> <Space>
<Input <Input
placeholder="输入设置键名查询" placeholder="输入设置键名查询"
prefix={<SearchOutlined style={{ color: '#94A3B8' }} />} prefix={<SearchOutlined style={{ color: '#94a3b8' }} />}
value={searchKey} value={searchKey}
onChange={(e) => setSearchKey(e.target.value)} onChange={(e) => setSearchKey(e.target.value)}
onPressEnter={handleSearch} onPressEnter={handleSearch}
@@ -207,7 +207,7 @@ export default function SystemSettings() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -5,8 +5,8 @@ import { listCompletedTasks, type TaskInfo } from '../../api/workflowTasks';
const outcomeStyles: Record<string, { bg: string; color: string; text: string }> = { const outcomeStyles: Record<string, { bg: string; color: string; text: string }> = {
approved: { bg: '#ECFDF5', color: '#059669', text: '同意' }, approved: { bg: '#ECFDF5', color: '#059669', text: '同意' },
rejected: { bg: '#FEF2F2', color: '#DC2626', text: '拒绝' }, rejected: { bg: '#FEF2F2', color: '#dc2626', text: '拒绝' },
delegated: { bg: '#EEF2FF', color: '#4F46E5', text: '已委派' }, delegated: { bg: '#eff6ff', color: '#2563eb', text: '已委派' },
}; };
export default function CompletedTasks() { export default function CompletedTasks() {
@@ -50,7 +50,7 @@ export default function CompletedTasks() {
key: 'outcome', key: 'outcome',
width: 100, width: 100,
render: (o: string) => { render: (o: string) => {
const info = outcomeStyles[o] || { bg: '#F1F5F9', color: '#64748B', text: o }; const info = outcomeStyles[o] || { bg: '#f8fafc', color: '#475569', text: o };
return ( return (
<Tag style={{ <Tag style={{
background: info.bg, background: info.bg,
@@ -69,7 +69,7 @@ export default function CompletedTasks() {
key: 'completed_at', key: 'completed_at',
width: 180, width: 180,
render: (v: string) => ( render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{v ? new Date(v).toLocaleString() : '-'} {v ? new Date(v).toLocaleString() : '-'}
</span> </span>
), ),
@@ -80,7 +80,7 @@ export default function CompletedTasks() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -13,10 +13,10 @@ import { getProcessDefinition, type NodeDef, type EdgeDef } from '../../api/work
import ProcessViewer from './ProcessViewer'; import ProcessViewer from './ProcessViewer';
const statusStyles: Record<string, { bg: string; color: string; text: string }> = { const statusStyles: Record<string, { bg: string; color: string; text: string }> = {
running: { bg: '#EEF2FF', color: '#4F46E5', text: '运行中' }, running: { bg: '#eff6ff', color: '#2563eb', text: '运行中' },
suspended: { bg: '#FFFBEB', color: '#D97706', text: '已挂起' }, suspended: { bg: '#FFFBEB', color: '#d97706', text: '已挂起' },
completed: { bg: '#ECFDF5', color: '#059669', text: '已完成' }, completed: { bg: '#ECFDF5', color: '#059669', text: '已完成' },
terminated: { bg: '#FEF2F2', color: '#DC2626', text: '已终止' }, terminated: { bg: '#FEF2F2', color: '#dc2626', text: '已终止' },
}; };
export default function InstanceMonitor() { export default function InstanceMonitor() {
@@ -129,7 +129,7 @@ export default function InstanceMonitor() {
key: 'status', key: 'status',
width: 100, width: 100,
render: (s: string) => { render: (s: string) => {
const info = statusStyles[s] || { bg: '#F1F5F9', color: '#64748B', text: s }; const info = statusStyles[s] || { bg: '#f8fafc', color: '#475569', text: s };
return ( return (
<Tag style={{ <Tag style={{
background: info.bg, background: info.bg,
@@ -154,7 +154,7 @@ export default function InstanceMonitor() {
key: 'started_at', key: 'started_at',
width: 180, width: 180,
render: (v: string) => ( render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{new Date(v).toLocaleString()} {new Date(v).toLocaleString()}
</span> </span>
), ),
@@ -214,7 +214,7 @@ export default function InstanceMonitor() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -76,9 +76,9 @@ export default function PendingTasks() {
key: 'business_key', key: 'business_key',
render: (v: string | undefined) => v ? ( render: (v: string | undefined) => v ? (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#0f172a' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 12, fontSize: 12,
}}> }}>
@@ -93,9 +93,9 @@ export default function PendingTasks() {
width: 100, width: 100,
render: (s: string) => ( render: (s: string) => (
<Tag style={{ <Tag style={{
background: '#EEF2FF', background: '#eff6ff',
border: 'none', border: 'none',
color: '#4F46E5', color: '#2563eb',
fontWeight: 500, fontWeight: 500,
}}> }}>
{s} {s}
@@ -108,7 +108,7 @@ export default function PendingTasks() {
key: 'created_at', key: 'created_at',
width: 180, width: 180,
render: (v: string) => ( render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}> <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{new Date(v).toLocaleString()} {new Date(v).toLocaleString()}
</span> </span>
), ),
@@ -145,7 +145,7 @@ export default function PendingTasks() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -13,9 +13,9 @@ import {
import ProcessDesigner from './ProcessDesigner'; import ProcessDesigner from './ProcessDesigner';
const statusColors: Record<string, { bg: string; color: string; text: string }> = { const statusColors: Record<string, { bg: string; color: string; text: string }> = {
draft: { bg: '#F1F5F9', color: '#64748B', text: '草稿' }, draft: { bg: '#f8fafc', color: '#475569', text: '草稿' },
published: { bg: '#ECFDF5', color: '#059669', text: '已发布' }, published: { bg: '#ecfdf5', color: '#059669', text: '已发布' },
deprecated: { bg: '#FEF2F2', color: '#DC2626', text: '已弃用' }, deprecated: { bg: '#fef2f2', color: '#dc2626', text: '已弃用' },
}; };
export default function ProcessDefinitions() { export default function ProcessDefinitions() {
@@ -92,9 +92,9 @@ export default function ProcessDefinitions() {
key: 'key', key: 'key',
render: (v: string) => ( render: (v: string) => (
<Tag style={{ <Tag style={{
background: isDark ? '#1E293B' : '#F1F5F9', background: isDark ? '#1E293B' : '#f8fafc',
border: 'none', border: 'none',
color: isDark ? '#94A3B8' : '#64748B', color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace', fontFamily: 'monospace',
fontSize: 12, fontSize: 12,
}}> }}>
@@ -110,7 +110,7 @@ export default function ProcessDefinitions() {
key: 'status', key: 'status',
width: 100, width: 100,
render: (s: string) => { render: (s: string) => {
const info = statusColors[s] || { bg: '#F1F5F9', color: '#64748B', text: s }; const info = statusColors[s] || { bg: '#f8fafc', color: '#475569', text: s };
return ( return (
<Tag style={{ <Tag style={{
background: info.bg, background: info.bg,
@@ -152,7 +152,7 @@ export default function ProcessDefinitions() {
alignItems: 'center', alignItems: 'center',
marginBottom: 16, marginBottom: 16,
}}> }}>
<span style={{ fontSize: 13, color: isDark ? '#64748B' : '#94A3B8' }}> <span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8' }}>
{total} {total}
</span> </span>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
@@ -163,7 +163,7 @@ export default function ProcessDefinitions() {
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12, borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`, border: `1px solid ${isDark ? '#1E293B' : '#f8fafc'}`,
overflow: 'hidden', overflow: 'hidden',
}}> }}>
<Table <Table

View File

@@ -11,7 +11,7 @@
*/ */
interface ExprNode { interface ExprNode {
type: 'eq' | 'and' | 'or' | 'not'; type: 'eq' | 'neq' | 'and' | 'or' | 'not';
field?: string; field?: string;
value?: string; value?: string;
left?: ExprNode; left?: ExprNode;
@@ -49,6 +49,16 @@ function tokenize(input: string): string[] {
i += 2; i += 2;
continue; continue;
} }
if (input[i] === '&' && input[i + 1] === '&') {
tokens.push('&&');
i += 2;
continue;
}
if (input[i] === '|' && input[i + 1] === '|') {
tokens.push('||');
i += 2;
continue;
}
let j = i; let j = i;
while ( while (
j < input.length && j < input.length &&
@@ -81,12 +91,12 @@ function parseAtom(tokens: string[]): ExprNode | null {
if (op !== '==' && op !== '!=') return null; if (op !== '==' && op !== '!=') return null;
const rawValue = tokens.shift() || ''; const rawValue = tokens.shift() || '';
const value = rawValue.replace(/^'(.*)'$/, '$1'); const value = rawValue.replace(/^'(.*)'$/, '$1');
return { type: 'eq', field, value }; return { type: op === '!=' ? 'neq' : 'eq', field, value };
} }
function parseAnd(tokens: string[]): ExprNode | null { function parseAnd(tokens: string[]): ExprNode | null {
let left = parseAtom(tokens); let left = parseAtom(tokens);
while (tokens[0] === 'AND') { while (tokens[0] === 'AND' || tokens[0] === '&&') {
tokens.shift(); tokens.shift();
const right = parseAtom(tokens); const right = parseAtom(tokens);
if (left && right) { if (left && right) {
@@ -98,7 +108,7 @@ function parseAnd(tokens: string[]): ExprNode | null {
function parseOr(tokens: string[]): ExprNode | null { function parseOr(tokens: string[]): ExprNode | null {
let left = parseAnd(tokens); let left = parseAnd(tokens);
while (tokens[0] === 'OR') { while (tokens[0] === 'OR' || tokens[0] === '||') {
tokens.shift(); tokens.shift();
const right = parseAnd(tokens); const right = parseAnd(tokens);
if (left && right) { if (left && right) {
@@ -117,6 +127,8 @@ export function evaluateExpr(node: ExprNode, values: Record<string, unknown>): b
switch (node.type) { switch (node.type) {
case 'eq': case 'eq':
return String(values[node.field!] ?? '') === node.value; return String(values[node.field!] ?? '') === node.value;
case 'neq':
return String(values[node.field!] ?? '') !== node.value;
case 'and': case 'and':
return evaluateExpr(node.left!, values) && evaluateExpr(node.right!, values); return evaluateExpr(node.left!, values) && evaluateExpr(node.right!, values);
case 'or': case 'or':

View File

@@ -136,11 +136,13 @@ is_public = true
name = "phone" name = "phone"
field_type = "string" field_type = "string"
display_name = "电话" display_name = "电话"
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
[[schema.entities.fields]] [[schema.entities.fields]]
name = "email" name = "email"
field_type = "string" field_type = "string"
display_name = "邮箱" display_name = "邮箱"
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
[[schema.entities.fields]] [[schema.entities.fields]]
name = "industry" name = "industry"
@@ -359,6 +361,7 @@ display_name = "报价单"
field_type = "decimal" field_type = "decimal"
display_name = "总金额" display_name = "总金额"
sortable = true sortable = true
visible_when = "status != 'draft'"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "notes" name = "notes"
@@ -452,12 +455,16 @@ display_name = "合同"
field_type = "uuid" field_type = "uuid"
display_name = "关联商机" display_name = "关联商机"
ref_entity = "opportunity" ref_entity = "opportunity"
cascade_from = "client_id"
cascade_filter = "client_id"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "quote_id" name = "quote_id"
field_type = "uuid" field_type = "uuid"
display_name = "关联报价" display_name = "关联报价"
ref_entity = "quote" ref_entity = "quote"
cascade_from = "client_id"
cascade_filter = "client_id"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "contract_number" name = "contract_number"
@@ -518,6 +525,7 @@ display_name = "合同"
field_type = "decimal" field_type = "decimal"
display_name = "已付金额" display_name = "已付金额"
default = 0 default = 0
visible_when = "status != 'drafting'"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "start_date" name = "start_date"
@@ -728,6 +736,7 @@ display_name = "任务"
name = "actual_hours" name = "actual_hours"
field_type = "decimal" field_type = "decimal"
display_name = "实际工时" display_name = "实际工时"
visible_when = "status != 'todo'"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "description" name = "description"
@@ -749,6 +758,8 @@ display_name = "工时记录"
ref_entity = "task" ref_entity = "task"
ref_label_field = "title" ref_label_field = "title"
ref_search_fields = ["title"] ref_search_fields = ["title"]
cascade_from = "project_id"
cascade_filter = "project_id"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "project_id" name = "project_id"
@@ -798,12 +809,16 @@ display_name = "发票/收款"
field_type = "uuid" field_type = "uuid"
display_name = "关联项目" display_name = "关联项目"
ref_entity = "project" ref_entity = "project"
cascade_from = "client_id"
cascade_filter = "client_id"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "contract_id" name = "contract_id"
field_type = "uuid" field_type = "uuid"
display_name = "关联合同" display_name = "关联合同"
ref_entity = "contract" ref_entity = "contract"
cascade_from = "client_id"
cascade_filter = "client_id"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "invoice_number" name = "invoice_number"
@@ -861,6 +876,7 @@ display_name = "发票/收款"
name = "payment_date" name = "payment_date"
field_type = "date" field_type = "date"
display_name = "实际收款日期" display_name = "实际收款日期"
visible_when = "status == 'paid' || status == 'partial'"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "notes" name = "notes"
@@ -920,6 +936,98 @@ display_name = "支出"
field_type = "string" field_type = "string"
display_name = "描述" display_name = "描述"
# ── 插件配置 ──
[settings]
[[settings.fields]]
name = "company_name"
display_name = "公司名称"
field_type = "text"
required = true
group = "基本信息"
[[settings.fields]]
name = "currency_symbol"
display_name = "货币符号"
field_type = "text"
default_value = "¥"
group = "基本信息"
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率(%)"
field_type = "number"
default_value = 6
range = [0.0, 100.0]
group = "财务"
[[settings.fields]]
name = "payment_reminder_days"
display_name = "收款提前提醒(天)"
field_type = "number"
default_value = 3
range = [1.0, 30.0]
group = "提醒"
[[settings.fields]]
name = "notify_contract_expiring"
display_name = "合同到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_payment_overdue"
display_name = "逾期收款提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_opportunity_followup"
display_name = "商机跟进提醒"
field_type = "boolean"
default_value = true
group = "提醒"
# ── 触发事件 ──
[[trigger_events]]
name = "opportunity_stage_changed"
display_name = "商机阶段变更"
description = "商机阶段发生变化时通知,特别是成交或失败"
entity = "opportunity"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "contract"
on = "update"
[[trigger_events]]
name = "invoice_status_changed"
display_name = "发票状态变更"
description = "发票状态变化时检查逾期收款"
entity = "invoice"
on = "update"
[[trigger_events]]
name = "task_status_changed"
display_name = "任务状态变更"
description = "任务完成或取消时通知"
entity = "task"
on = "update"
[[trigger_events]]
name = "expense_created"
display_name = "新支出记录"
description = "记录新支出时通知"
entity = "expense"
on = "create"
# ── 编号规则 ── # ── 编号规则 ──
[[numbering]] [[numbering]]
@@ -943,6 +1051,26 @@ prefix = "INV"
format = "{PREFIX}-{YEAR}-{SEQ}" format = "{PREFIX}-{YEAR}-{SEQ}"
seq_length = 4 seq_length = 4
# ── 打印模板 ──
[[templates]]
name = "quote_pdf"
display_name = "报价单"
entity = "quote"
format = "pdf"
[[templates]]
name = "invoice_pdf"
display_name = "发票"
entity = "invoice"
format = "pdf"
[[templates]]
name = "contract_pdf"
display_name = "合同"
entity = "contract"
format = "pdf"
# ── 页面设计 ── # ── 页面设计 ──
# 页面 1全局工作台 # 页面 1全局工作台
@@ -951,6 +1079,49 @@ type = "dashboard"
label = "工作台" label = "工作台"
icon = "DashboardOutlined" icon = "DashboardOutlined"
# ── 财务概览卡片 ──
[[ui.pages.widgets]]
type = "stat_cards"
label = "财务概览"
cards = [
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "type == 'payment' && status != 'overdue'", label = "本月收入", icon = "rise", color = "green" },
{ entity = "expense", aggregate = "sum", field = "amount", label = "本月支出", icon = "fall", color = "red" },
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "status == 'overdue' || status == 'pending'", label = "应收总额", icon = "dollar", color = "orange" },
{ entity = "invoice", aggregate = "count", filter = "status == 'overdue'", label = "逾期笔数", icon = "warning", color = "red" }
]
# ── 紧急待办 ──
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "invoice", filter = "status == 'overdue'", label_field = "invoice_number", subtitle_field = "amount", action = "查看", icon = "warning" },
{ entity = "task", filter = "status != 'done' && status != 'cancelled'", sort = "due_date asc", label_field = "title", subtitle_field = "due_date", action = "处理", icon = "clock" },
{ entity = "contract", filter = "status == 'active'", sort = "end_date asc", label_field = "title", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "opportunity", filter = "next_follow_up <= today", label_field = "title", subtitle_field = "next_follow_up", action = "跟进", icon = "phone" }
]
# ── 商机漏斗 ──
[[ui.pages.widgets]]
type = "funnel"
label = "商机漏斗"
entity = "opportunity"
lane_field = "stage"
value_field = "estimated_amount"
lane_order = ["visit", "requirement", "quote", "negotiation", "won", "lost"]
# ── 活跃项目卡片 ──
[[ui.pages.widgets]]
type = "card_list"
label = "活跃项目"
entity = "project"
filter = "status == 'in_progress'"
max_items = 4
title_field = "name"
subtitle_field = "contract_amount"
tags = ["business_type", "status"]
# 页面 2客户管理列表 + 详情 + 商机看板) # 页面 2客户管理列表 + 详情 + 商机看板)
[[ui.pages]] [[ui.pages]]
type = "tabs" type = "tabs"

View File

@@ -76,6 +76,7 @@ display_name = "维保合同"
required = true required = true
display_name = "合同编号" display_name = "合同编号"
unique = true unique = true
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式SC-YYYY-NNNN" }
[[schema.entities.fields]] [[schema.entities.fields]]
name = "name" name = "name"
@@ -190,6 +191,8 @@ display_name = "工单"
ref_entity = "service_contract" ref_entity = "service_contract"
ref_label_field = "name" ref_label_field = "name"
ref_search_fields = ["name"] ref_search_fields = ["name"]
cascade_from = "client_id"
cascade_filter = "client_id"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "client_id" name = "client_id"
@@ -279,21 +282,25 @@ display_name = "工单"
field_type = "string" field_type = "string"
display_name = "解决方案" display_name = "解决方案"
ui_widget = "textarea" ui_widget = "textarea"
visible_when = "status == 'resolved' || status == 'closed'"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "responded_at" name = "responded_at"
field_type = "date_time" field_type = "date_time"
display_name = "首次响应时间" display_name = "首次响应时间"
visible_when = "status != 'open'"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "resolved_at" name = "resolved_at"
field_type = "date_time" field_type = "date_time"
display_name = "解决时间" display_name = "解决时间"
visible_when = "status == 'resolved' || status == 'closed'"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "closed_at" name = "closed_at"
field_type = "date_time" field_type = "date_time"
display_name = "关闭时间" display_name = "关闭时间"
visible_when = "status == 'closed'"
# ── 3.3.3 check_plan巡检计划── # ── 3.3.3 check_plan巡检计划──
@@ -399,6 +406,8 @@ display_name = "巡检记录"
field_type = "uuid" field_type = "uuid"
display_name = "维保合同" display_name = "维保合同"
ref_entity = "service_contract" ref_entity = "service_contract"
cascade_from = "plan_id"
cascade_filter = "contract_id"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "client_id" name = "client_id"
@@ -439,12 +448,14 @@ display_name = "巡检记录"
field_type = "string" field_type = "string"
display_name = "发现的问题" display_name = "发现的问题"
ui_widget = "textarea" ui_widget = "textarea"
visible_when = "result == 'abnormal'"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "actions_taken" name = "actions_taken"
field_type = "string" field_type = "string"
display_name = "采取措施" display_name = "采取措施"
ui_widget = "textarea" ui_widget = "textarea"
visible_when = "result == 'abnormal'"
[[schema.entities.fields]] [[schema.entities.fields]]
name = "notes" name = "notes"
@@ -452,6 +463,70 @@ display_name = "巡检记录"
display_name = "备注" display_name = "备注"
ui_widget = "textarea" ui_widget = "textarea"
# ── 插件配置 ──
[settings]
[[settings.fields]]
name = "default_sla_response"
display_name = "默认SLA响应时间(小时)"
field_type = "number"
default_value = 8
range = [1.0, 72.0]
group = "SLA"
[[settings.fields]]
name = "default_sla_resolve"
display_name = "默认SLA解决时间(小时)"
field_type = "number"
default_value = 48
range = [1.0, 168.0]
group = "SLA"
[[settings.fields]]
name = "notify_sla_breach"
display_name = "SLA超标提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_check_due"
display_name = "巡检到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
# ── 触发事件 ──
[[trigger_events]]
name = "ticket_created"
display_name = "新工单"
description = "创建工单时开始SLA计时并通知"
entity = "ticket"
on = "create"
[[trigger_events]]
name = "ticket_status_changed"
display_name = "工单状态变更"
description = "工单状态变化时检查SLA是否达标"
entity = "ticket"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "维保合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "service_contract"
on = "update"
[[trigger_events]]
name = "check_plan_updated"
display_name = "巡检计划更新"
description = "巡检计划更新时检查下次巡检日期"
entity = "check_plan"
on = "update"
# ── 编号规则 ── # ── 编号规则 ──
[[numbering]] [[numbering]]
@@ -461,8 +536,42 @@ prefix = "SC"
format = "{PREFIX}-{YEAR}-{SEQ}" format = "{PREFIX}-{YEAR}-{SEQ}"
seq_length = 4 seq_length = 4
# ── 打印模板 ──
[[templates]]
name = "service_contract_pdf"
display_name = "维保合同"
entity = "service_contract"
format = "pdf"
# ── 页面设计 ── # ── 页面设计 ──
# 页面 0运维概览仪表盘
[[ui.pages]]
type = "dashboard"
label = "运维概览"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "运维概览"
cards = [
{ entity = "service_contract", aggregate = "count", filter = "status == 'active'", label = "活跃合同", icon = "file-text", color = "blue" },
{ entity = "ticket", aggregate = "count", filter = "status == 'open' || status == 'in_progress'", label = "待处理工单", icon = "tool", color = "orange" },
{ entity = "ticket", aggregate = "count", filter = "status == 'resolved'", label = "已解决工单", icon = "check-circle", color = "green" },
{ entity = "check_plan", aggregate = "count", filter = "status == 'active'", label = "活跃巡检", icon = "schedule", color = "blue" }
]
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "ticket", filter = "status == 'open'", sort = "priority asc", label_field = "title", subtitle_field = "type", action = "处理", icon = "warning" },
{ entity = "service_contract", filter = "status == 'active'", sort = "end_date asc", label_field = "name", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "check_plan", filter = "status == 'active'", sort = "next_check_date asc", label_field = "name", subtitle_field = "next_check_date", action = "巡检", icon = "schedule" }
]
# 页面 1合同管理 + 详情 # 页面 1合同管理 + 详情
[[ui.pages]] [[ui.pages]]
type = "crud" type = "crud"

View File

@@ -284,6 +284,8 @@ pub enum PluginPageType {
label: String, label: String,
#[serde(default)] #[serde(default)]
icon: Option<String>, icon: Option<String>,
#[serde(default)]
widgets: Vec<PluginWidget>,
}, },
#[serde(rename = "kanban")] #[serde(rename = "kanban")]
Kanban { Kanban {
@@ -304,6 +306,80 @@ pub enum PluginPageType {
}, },
} }
/// Dashboard Widget 类型
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginWidget {
#[serde(rename = "stat_cards")]
StatCards {
label: String,
cards: Vec<StatCard>,
},
#[serde(rename = "action_list")]
ActionList {
label: String,
#[serde(default)]
max_items: Option<u32>,
queries: Vec<ActionQuery>,
},
#[serde(rename = "funnel")]
Funnel {
label: String,
entity: String,
lane_field: String,
#[serde(default)]
value_field: Option<String>,
lane_order: Vec<String>,
},
#[serde(rename = "card_list")]
CardList {
label: String,
entity: String,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
max_items: Option<u32>,
title_field: String,
#[serde(default)]
subtitle_field: Option<String>,
#[serde(default)]
tags: Vec<String>,
},
}
/// 统计卡片
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct StatCard {
pub entity: String,
#[serde(default)]
pub aggregate: Option<String>,
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub filter: Option<String>,
pub label: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
}
/// 待办行动查询
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ActionQuery {
pub entity: String,
#[serde(default)]
pub filter: Option<String>,
#[serde(default)]
pub sort: Option<String>,
pub label_field: String,
#[serde(default)]
pub subtitle_field: Option<String>,
pub action: String,
#[serde(default)]
pub icon: Option<String>,
}
/// 插件页面区段(用于 detail 页面类型) /// 插件页面区段(用于 detail 页面类型)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type")] #[serde(tag = "type")]
@@ -1553,4 +1629,153 @@ name = "管理发票"
assert_eq!(entities[0].importable, Some(true)); assert_eq!(entities[0].importable, Some(true));
assert_eq!(entities[0].exportable, Some(true)); assert_eq!(entities[0].exportable, Some(true));
} }
#[test]
fn parse_dashboard_with_widgets() {
let toml = r##"
[metadata]
id = "test"
name = "Test"
version = "0.1.0"
[schema]
[[schema.entities]]
name = "invoice"
display_name = "发票"
[[schema.entities.fields]]
name = "status"
field_type = "string"
display_name = "状态"
[[schema.entities.fields]]
name = "amount"
field_type = "decimal"
display_name = "金额"
[ui]
[[ui.pages]]
type = "dashboard"
label = "工作台"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "财务概览"
[[ui.pages.widgets.cards]]
entity = "invoice"
aggregate = "count"
label = "总发票"
icon = "FileTextOutlined"
color = "#1890ff"
[[ui.pages.widgets.cards]]
entity = "invoice"
aggregate = "sum"
field = "amount"
filter = "status == 'pending'"
label = "待收金额"
icon = "DollarOutlined"
color = "#faad14"
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
[[ui.pages.widgets.queries]]
entity = "invoice"
filter = "status == 'overdue'"
sort = "due_date asc"
label_field = "invoice_number"
subtitle_field = "amount"
action = "open_invoice"
icon = "warning"
[[ui.pages.widgets]]
type = "funnel"
label = "商机漏斗"
entity = "invoice"
lane_field = "status"
value_field = "amount"
lane_order = ["pending", "issued", "paid"]
[[ui.pages.widgets]]
type = "card_list"
label = "活跃项目"
entity = "invoice"
filter = "status == 'active'"
max_items = 10
title_field = "invoice_number"
subtitle_field = "amount"
tags = ["status"]
"##;
let manifest = parse_manifest(toml).unwrap();
let ui = manifest.ui.unwrap();
assert_eq!(ui.pages.len(), 1);
match &ui.pages[0] {
PluginPageType::Dashboard {
label, icon, widgets,
} => {
assert_eq!(label, "工作台");
assert_eq!(icon.as_deref(), Some("DashboardOutlined"));
assert_eq!(widgets.len(), 4);
// stat_cards
match &widgets[0] {
PluginWidget::StatCards { label, cards } => {
assert_eq!(label, "财务概览");
assert_eq!(cards.len(), 2);
assert_eq!(cards[0].entity, "invoice");
assert_eq!(cards[0].aggregate.as_deref(), Some("count"));
assert_eq!(cards[1].aggregate.as_deref(), Some("sum"));
assert_eq!(cards[1].filter.as_deref(), Some("status == 'pending'"));
}
_ => panic!("Expected StatCards"),
}
// action_list
match &widgets[1] {
PluginWidget::ActionList {
label, max_items, queries,
} => {
assert_eq!(label, "紧急待办");
assert_eq!(*max_items, Some(5));
assert_eq!(queries.len(), 1);
assert_eq!(queries[0].entity, "invoice");
assert_eq!(queries[0].action, "open_invoice");
}
_ => panic!("Expected ActionList"),
}
// funnel
match &widgets[2] {
PluginWidget::Funnel {
label, entity, lane_field, value_field, lane_order,
} => {
assert_eq!(label, "商机漏斗");
assert_eq!(entity, "invoice");
assert_eq!(lane_field, "status");
assert_eq!(value_field.as_deref(), Some("amount"));
assert_eq!(lane_order, &["pending", "issued", "paid"]);
}
_ => panic!("Expected Funnel"),
}
// card_list
match &widgets[3] {
PluginWidget::CardList {
label, entity, title_field, ..
} => {
assert_eq!(label, "活跃项目");
assert_eq!(entity, "invoice");
assert_eq!(title_field, "invoice_number");
}
_ => panic!("Expected CardList"),
}
}
_ => panic!("Expected Dashboard page type"),
}
}
} }

View File

@@ -0,0 +1,587 @@
# freelance + itops 插件增强实施计划
> 日期: 2026-04-20
> 对应规格: `docs/superpowers/specs/2026-04-20-freelance-itops-plugin-enhancement-design.md`
> 前置: 两插件已部署freelance 10 实体/20 权限itops 4 实体/8 权限)
---
## 总览
| Phase | 内容 | 类型 | 依赖 |
|-------|------|------|------|
| P1 | freelance Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
| P2 | itops Layer 1 — 智能业务引擎 | 纯 plugin.toml | 无 |
| P3 | freelance Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
| P4 | itops Layer 3 — PDF 模板 | 纯 plugin.toml | 无 |
| P5 | 平台 dashboard widgets 扩展 | manifest.rs + 前端 | P1-P4 完成 |
| P6 | freelance + itops Layer 2 — 仪表盘 | plugin.toml + 前端 | P5 完成 |
P1-P4 可并行P5-P6 顺序依赖。
---
## Phase 1: freelance Layer 1 — 智能业务引擎
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
### Task 1.1: 新增 `[settings]` 段落
`[[numbering]]` 之前插入 7 个配置项:
```toml
[settings]
[[settings.fields]]
name = "company_name"
display_name = "公司名称"
field_type = "text"
required = true
group = "基本信息"
[[settings.fields]]
name = "currency_symbol"
display_name = "货币符号"
field_type = "text"
default_value = "¥"
group = "基本信息"
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率(%)"
field_type = "number"
default_value = 6
range = [0.0, 100.0]
group = "财务"
[[settings.fields]]
name = "payment_reminder_days"
display_name = "收款提前提醒(天)"
field_type = "number"
default_value = 3
range = [1.0, 30.0]
group = "提醒"
[[settings.fields]]
name = "notify_contract_expiring"
display_name = "合同到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_payment_overdue"
display_name = "逾期收款提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_opportunity_followup"
display_name = "商机跟进提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### Task 1.2: 新增 `[[trigger_events]]` 段落
`[settings]` 之后插入 5 个触发事件:
```toml
[[trigger_events]]
name = "opportunity_stage_changed"
display_name = "商机阶段变更"
description = "商机阶段发生变化时通知,特别是成交或失败"
entity = "opportunity"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "contract"
on = "update"
[[trigger_events]]
name = "invoice_status_changed"
display_name = "发票状态变更"
description = "发票状态变化时检查逾期收款"
entity = "invoice"
on = "update"
[[trigger_events]]
name = "task_status_changed"
display_name = "任务状态变更"
description = "任务完成或取消时通知"
entity = "task"
on = "update"
[[trigger_events]]
name = "expense_created"
display_name = "新支出记录"
description = "记录新支出时通知"
entity = "expense"
on = "create"
```
### Task 1.3: 追加 cascade 属性5 处已有字段)
**1.3a** contract.opportunity_id第 450-454 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3b** contract.quote_id第 456-460 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3c** invoice.project_id第 796-800 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3d** invoice.contract_id第 802-806 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**1.3e** time_entry.task_id第 745-751 行)追加:
```toml
cascade_from = "project_id"
cascade_filter = "project_id"
```
### Task 1.4: 追加 visible_when 属性4 处已有字段)
**1.4a** invoice.payment_date第 860-863 行)追加:
```toml
visible_when = "status == 'paid' || status == 'partial'"
```
**1.4b** contract.paid_amount第 516-520 行)追加:
```toml
visible_when = "status != 'drafting'"
```
**1.4c** task.actual_hours第 727-730 行)追加:
```toml
visible_when = "status != 'todo'"
```
**1.4d** quote.total_amount第 357-361 行)追加:
```toml
visible_when = "status != 'draft'"
```
### Task 1.5: 追加 validation 属性2 处已有字段)
**1.5a** client.phone第 135-138 行)追加:
```toml
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
```
**1.5b** client.email第 140-143 行)追加:
```toml
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
```
### Task 1.6: 编译 WASM + 升级插件
```bash
cargo build -p erp-plugin-freelance --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_freelance.wasm -o target/erp_plugin_freelance.component.wasm
```
通过 API 升级:
```bash
# 上传新版本 WASM
curl -X POST http://localhost:3000/api/v1/admin/plugins/{plugin_id}/upgrade \
-H "Authorization: Bearer {token}" \
-F "wasm=@target/erp_plugin_freelance.component.wasm" \
-F "manifest=@crates/erp-plugin-freelance/plugin.toml"
```
### Task 1.7: 验证
- [ ] `cargo check` 通过
- [ ] 重新登录获取新 JWT权限可能变化
- [ ] 前端打开 freelance 插件 → 设置页面可见 7 个配置项
- [ ] 创建客户 → phone 格式错误时提示校验信息
- [ ] 创建客户 → email 格式错误时提示校验信息
- [ ] 创建合同 → 选客户后 opportunity_id 和 quote_id 自动过滤
- [ ] 创建发票 → 选客户后 project_id 和 contract_id 自动过滤
- [ ] 创建工时 → 选项目后 task_id 自动过滤
- [ ] invoice 状态为 pending 时payment_date 字段不显示
- [ ] contract 状态为 drafting 时paid_amount 字段不显示
- [ ] 触发事件:更新商机阶段 → 消息中心收到通知
- [ ] `git add && git commit && git push`
---
## Phase 2: itops Layer 1 — 智能业务引擎
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
### Task 2.1: 新增 `[settings]` 段落
`[[numbering]]` 之前插入 4 个配置项:
```toml
[settings]
[[settings.fields]]
name = "default_sla_response"
display_name = "默认SLA响应时间(小时)"
field_type = "number"
default_value = 8
range = [1.0, 72.0]
group = "SLA"
[[settings.fields]]
name = "default_sla_resolve"
display_name = "默认SLA解决时间(小时)"
field_type = "number"
default_value = 48
range = [1.0, 168.0]
group = "SLA"
[[settings.fields]]
name = "notify_sla_breach"
display_name = "SLA超标提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_check_due"
display_name = "巡检到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### Task 2.2: 新增 `[[trigger_events]]` 段落
`[settings]` 之后插入 4 个触发事件:
```toml
[[trigger_events]]
name = "ticket_created"
display_name = "新工单"
description = "创建工单时开始SLA计时并通知"
entity = "ticket"
on = "create"
[[trigger_events]]
name = "ticket_status_changed"
display_name = "工单状态变更"
description = "工单状态变化时检查SLA是否达标"
entity = "ticket"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "维保合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "service_contract"
on = "update"
[[trigger_events]]
name = "check_plan_updated"
display_name = "巡检计划更新"
description = "巡检计划更新时检查下次巡检日期"
entity = "check_plan"
on = "update"
```
### Task 2.3: 追加 cascade 属性2 处已有字段)
**2.3a** ticket.contract_id第 186-192 行)追加:
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**2.3b** check_record.contract_id第 398-400 行)追加:
```toml
cascade_from = "plan_id"
cascade_filter = "contract_id"
```
### Task 2.4: 追加 visible_when 属性6 处已有字段)
**2.4a** ticket.resolution → `visible_when = "status == 'resolved' || status == 'closed'"`
**2.4b** ticket.responded_at → `visible_when = "status != 'open'"`
**2.4c** ticket.resolved_at → `visible_when = "status == 'resolved' || status == 'closed'"`
**2.4d** ticket.closed_at → `visible_when = "status == 'closed'"`
**2.4e** check_record.issues_found → `visible_when = "result == 'abnormal'"`
**2.4f** check_record.actions_taken → `visible_when = "result == 'abnormal'"`
### Task 2.5: 追加 validation 属性1 处已有字段)
**2.5a** service_contract.contract_number第 73-78 行)追加:
```toml
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式SC-YYYY-NNNN" }
```
### Task 2.6: 编译 WASM + 升级插件
```bash
cargo build -p erp-plugin-itops --target wasm32-unknown-unknown --release
wasm-tools component new target/wasm32-unknown-unknown/release/erp_plugin_itops.wasm -o target/erp_plugin_itops.component.wasm
```
### Task 2.7: 验证
- [ ] `cargo check` 通过
- [ ] 重新登录获取新 JWT
- [ ] 前端打开 itops 插件 → 设置页面可见 4 个配置项
- [ ] 创建工单 → 选客户后 contract_id 自动过滤
- [ ] 工单状态为 open 时resolution/resolved_at/closed_at 不显示
- [ ] 工单状态改为 resolved → resolution 和 resolved_at 出现
- [ ] 巡检记录结果为 normal → issues_found/actions_taken 不显示
- [ ] 巡检记录结果改为 abnormal → issues_found/actions_taken 出现
- [ ] 触发事件:创建工单 → 消息中心收到通知
- [ ] `git add && git commit && git push`
---
## Phase 3: freelance Layer 3 — PDF 模板
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
### Task 3.1: 新增 `[[templates]]` 段落3 个模板)
`[[ui.pages]]` 之前插入报价单、发票、合同 3 个 PDF 模板。
**报价单模板** (`quote_pdf`)
- entity = "quote"
- 包含 Handlebars 语法:`{{quote_number}}`, `{{client.name}}`, `{{#each lines}}`
- 表格渲染item_name / description / quantity / unit_price / amount
- 底部subtotal / tax_rate / total_amount
**发票模板** (`invoice_pdf`)
- entity = "invoice"
- grid 布局client.name / type / issue_date / due_date
- 大字金额:`¥{{amount}}`
- 状态 badge
**合同模板** (`contract_pdf`)
- entity = "contract"
- 签章区域:甲方/乙方
- parties 区块client.name / amount / paid_amount / 期限 / payment_terms
### Task 3.2: 编译 WASM + 升级
同 Task 1.6 流程。
### Task 3.3: 验证
- [ ] 前端打开报价单详情 → 可见"生成 PDF"按钮
- [ ] 点击生成 → 下载 PDF内容包含正确的字段值
- [ ] 发票 PDF → 金额/客户名正确
- [ ] 合同 PDF → 签章区域正确
- [ ] `git add && git commit && git push`
---
## Phase 4: itops Layer 3 — 维保合同 PDF 模板
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
### Task 4.1: 新增 `[[templates]]` 段落1 个模板)
维保合同模板 (`service_contract_pdf`)
- entity = "service_contract"
- SLA 承诺框:响应/解决时间
- grid 布局client.name / amount / 期限 / status
- 服务范围 / 付款条款 / 签章区
### Task 4.2: 编译 WASM + 升级
同 Task 2.6 流程。
### Task 4.3: 验证
- [ ] 前端打开维保合同详情 → 可见"生成 PDF"按钮
- [ ] 点击生成 → 下载 PDFSLA 承诺正确
- [ ] `git add && git commit && git push`
---
## Phase 5: 平台 dashboard widgets 扩展
> **注意:** 此阶段需要修改平台 Rust 代码 + 前端代码,不是纯 plugin.toml 改动。
### Task 5.1: 扩展 manifest.rs — 定义 PluginWidget 类型
**目标文件:** `crates/erp-plugin/src/manifest.rs`
`PluginPageType::Dashboard` 结构体中新增 `widgets` 字段:
```rust
// PluginPageType::Dashboard 新增字段
Dashboard {
label: String,
#[serde(default)]
icon: Option<String>,
#[serde(default)]
widgets: Option<Vec<PluginWidget>>, // 新增
},
```
定义 `PluginWidget` 枚举及其子类型:
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PluginWidget {
StatCards {
label: String,
cards: Vec<StatCard>,
},
ActionList {
label: String,
#[serde(default)]
max_items: Option<u32>,
queries: Vec<ActionQuery>,
},
Funnel {
label: String,
entity: String,
lane_field: String,
#[serde(default)]
value_field: Option<String>,
lane_order: Vec<String>,
},
CardList {
label: String,
entity: String,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
max_items: Option<u32>,
title_field: String,
#[serde(default)]
subtitle_field: Option<String>,
#[serde(default)]
tags: Vec<String>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatCard {
pub entity: String,
#[serde(default)]
pub aggregate: Option<String>, // count, sum
#[serde(default)]
pub field: Option<String>,
#[serde(default)]
pub filter: Option<String>,
pub label: String,
#[serde(default)]
pub icon: Option<String>,
#[serde(default)]
pub color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActionQuery {
pub entity: String,
#[serde(default)]
pub filter: Option<String>,
#[serde(default)]
pub sort: Option<String>,
pub label_field: String,
#[serde(default)]
pub subtitle_field: Option<String>,
pub action: String,
#[serde(default)]
pub icon: Option<String>,
}
```
### Task 5.2: 扩展插件 API — 返回 widgets 数据
**目标文件:** `crates/erp-plugin/src/module.rs`
新增 API 端点,为 dashboard widgets 提供数据:
- `GET /api/v1/plugins/{plugin_id}/dashboard/widgets` — 返回 widgets 定义
- `GET /api/v1/plugins/{plugin_id}/dashboard/data` — 返回 widgets 聚合数据(调用已有 count/aggregate API
### Task 5.3: 前端渲染 dashboard widgets
**目标目录:** `apps/web/src/`
新增组件:
- `PluginDashboard.tsx` — 仪表盘容器,读取 widgets 定义并渲染
- `StatCardsWidget.tsx` — 统计卡片组件4 个指标卡片)
- `ActionListWidget.tsx` — 待办列表组件
- `FunnelWidget.tsx` — 漏斗图组件
- `CardListWidget.tsx` — 卡片列表组件
### Task 5.4: 验证
- [ ] `cargo check` 通过
- [ ] 前端 `pnpm build` 通过
- [ ] manifest.rs 正确解析 widgets TOML
- [ ] API 返回 widgets 定义和聚合数据
- [ ] `git add && git commit && git push`
---
## Phase 6: freelance + itops Layer 2 — 仪表盘 widgets
> **前置:** Phase 5 完成(平台支持 widgets
### Task 6.1: freelance — 替换仪表盘页面为 widgets 版本
**目标文件:** `crates/erp-plugin-freelance/plugin.toml`
将现有的空仪表盘(第 949-952 行)替换为包含 4 个 widgets 的完整仪表盘:
1. stat_cards — 财务概览4 张卡片)
2. action_list — 紧急待办4 种查询)
3. funnel — 商机漏斗
4. card_list — 活跃项目
### Task 6.2: itops — 新增仪表盘页面到最前面
**目标文件:** `crates/erp-plugin-itops/plugin.toml`
在现有页面列表最前面插入仪表盘页面2 个 widgets
1. stat_cards — 运维概览4 张卡片)
2. action_list — 紧急待办3 种查询)
### Task 6.3: 两个插件各自编译 WASM + 升级
### Task 6.4: 验证
- [ ] freelance 仪表盘 → 4 个 widget 正确渲染
- [ ] itops 仪表盘 → 2 个 widget 正确渲染
- [ ] 财务卡片数值正确(调用 aggregate API
- [ ] 紧急待办列表有数据时显示条目
- [ ] 商机漏斗按阶段显示金额分布
- [ ] `git add && git commit && git push`
---
## 执行策略
**P1-P4 并行策略:** P1 和 P2 可以同时开始不同文件P3 和 P4 在 P1/P2 完成后立即跟进。每个 Phase 独立编译 WASM、独立验证、独立提交。
**P5-P6 顺序策略:** P5 是平台改动Rust + 前端P6 依赖 P5 的平台能力才能生效。
**预估工作量:**
- P1: 30-40 分钟plugin.toml 编辑 + 编译 + 验证)
- P2: 20-30 分钟(规模小于 P1
- P3: 15-20 分钟3 个模板插入)
- P4: 10-15 分钟1 个模板插入)
- P5: 60-90 分钟manifest 扩展 + API + 前端组件)
- P6: 20-30 分钟plugin.toml widgets 声明)

View File

@@ -0,0 +1,624 @@
# freelance + itops 插件增强设计规格
> 日期: 2026-04-20
> 来源: 多专家头脑风暴UX专家 + 业务顾问 + 运维专家 + 财务专家)
> 状态: Draft
> 前置: `docs/superpowers/specs/2026-04-19-shantou-zhijie-it-services-plugins-design.md`
---
## 1. 背景与动机
当前插件是「数据录入系统」,不是「赚钱工具」。一人 IT 服务公司的核心痛点:
1. **钱从哪里来?** — 商机跟进靠人记,没有自动提醒、没有漏斗分析
2. **项目做到哪了?** — 任务状态和工时手动填,跟合同金额/应收款脱节
3. **钱收回来了吗?** — 报价→合同→开票→收款割裂,没有串联
4. **运维服务会不会忘?** — 巡检计划写了没人催SLA 超时了才知道
5. **税和利润算不清?** — 收支分散在不同表里,月底做账要手动汇总
**问题根因:** 平台已有 trigger_events、settings、templates、cascade_from、visible_when、validation 六大能力,但两个插件完全没有使用。
**改进目标:** 纯插件层增强,三层递进:
- Layer 1: 智能业务引擎 — 让系统主动驱动用户做事
- Layer 2: 仪表盘重构 — 一个页面掌控全局
- Layer 3: 专业输出 — 一键生成报价单/发票/合同 PDF
---
## 2. Layer 1: 智能业务引擎 — freelance 插件
### 2.1 Settings插件配置页
一次性配置公司信息和业务偏好,后续自动生效:
```toml
[settings]
# ── 基本信息 ──
[[settings.fields]]
name = "company_name"
display_name = "公司名称"
field_type = "text"
required = true
group = "基本信息"
[[settings.fields]]
name = "currency_symbol"
display_name = "货币符号"
field_type = "text"
default_value = "¥"
group = "基本信息"
# ── 财务 ──
[[settings.fields]]
name = "default_tax_rate"
display_name = "默认税率(%)"
field_type = "number"
default_value = 6
range = [0.0, 100.0]
group = "财务"
# ── 提醒 ──
[[settings.fields]]
name = "payment_reminder_days"
display_name = "收款提前提醒(天)"
field_type = "number"
default_value = 3
range = [1.0, 30.0]
group = "提醒"
[[settings.fields]]
name = "notify_contract_expiring"
display_name = "合同到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_payment_overdue"
display_name = "逾期收款提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_opportunity_followup"
display_name = "商机跟进提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### 2.2 Trigger Events自动事件驱动
关键操作时自动发通知,把"人找事"变"事找人"
```toml
[[trigger_events]]
name = "opportunity_stage_changed"
display_name = "商机阶段变更"
description = "商机阶段发生变化时通知,特别是成交或失败"
entity = "opportunity"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "contract"
on = "update"
[[trigger_events]]
name = "invoice_status_changed"
display_name = "发票状态变更"
description = "发票状态变化时检查逾期收款"
entity = "invoice"
on = "update"
[[trigger_events]]
name = "task_status_changed"
display_name = "任务状态变更"
description = "任务完成或取消时通知"
entity = "task"
on = "update"
[[trigger_events]]
name = "expense_created"
display_name = "新支出记录"
description = "记录新支出时通知"
entity = "expense"
on = "create"
```
### 2.3 Cascade智能联动下拉
选客户后自动过滤其关联数据。以下均为**已有字段追加 cascade 属性**,不是新增字段:
**contract 实体 — 已有 opportunity_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**contract 实体 — 已有 quote_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**invoice 实体 — 已有 project_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**invoice 实体 — 已有 contract_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**time_entry 实体 — 已有 task_id 字段追加:**
```toml
cascade_from = "project_id"
cascade_filter = "project_id"
```
### 2.4 Visible When条件显示
只在有意义时才显示字段。以下为**已有字段追加 visible_when 属性**
**invoice 实体 — 已有 payment_date 字段追加:**
```toml
visible_when = "status == 'paid' || status == 'partial'"
```
**contract 实体 — 已有 paid_amount 字段追加:**
```toml
visible_when = "status != 'drafting'"
```
**task 实体 — 已有 actual_hours 字段追加:**
```toml
visible_when = "status != 'todo'"
```
**quote 实体 — 已有 total_amount 字段追加:**
```toml
visible_when = "status != 'draft'"
```
### 2.5 Validation字段校验
**已有字段追加 validation 属性**,不是新增字段:
**client 实体 — 已有 email 字段追加:**
```toml
validation = { pattern = "^[^@]+@[^@]+\\.[^@]+$", message = "请输入有效的邮箱地址" }
```
**client 实体 — 已有 phone 字段追加:**
```toml
validation = { pattern = "^1[3-9]\\d{9}$", message = "请输入有效的手机号" }
```
---
## 3. Layer 2: 仪表盘重构 — freelance 插件
将占位符仪表盘升级为真正的指挥中心。通过 `widgets` 声明告诉平台该展示什么。
> **平台依赖:** 仪表盘 widgets 需要平台层配合:
> 1. `manifest.rs` 的 `PluginPageType::Dashboard` 需要新增 `widgets: Option<Vec<PluginWidget>>` 字段
> 2. 定义 `PluginWidget` 枚举stat_cards/action_list/funnel/card_list 类型)
> 3. 更新 TOML 解析和验证逻辑
> 4. 前端解析 `widgets` 声明并渲染对应组件
>
> 因此 P5/P6 **不是纯 plugin.toml 改动**,需要平台+前端联合实施。以下 widgets 声明作为设计参考,实施时需先完成平台侧支持。
```toml
[[ui.pages]]
type = "dashboard"
label = "工作台"
icon = "DashboardOutlined"
# ── 财务概览卡片 ──
[[ui.pages.widgets]]
type = "stat_cards"
label = "财务概览"
cards = [
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "type == 'payment' && status != 'overdue'", label = "本月收入", icon = "rise", color = "green" },
{ entity = "expense", aggregate = "sum", field = "amount", label = "本月支出", icon = "fall", color = "red" },
{ entity = "invoice", aggregate = "sum", field = "amount", filter = "status == 'overdue' || status == 'pending'", label = "应收总额", icon = "dollar", color = "orange" },
{ entity = "invoice", aggregate = "count", filter = "status == 'overdue'", label = "逾期笔数", icon = "warning", color = "red" }
]
# ── 紧急待办 ──
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "invoice", filter = "status == 'overdue'", label_field = "invoice_number", subtitle_field = "amount", action = "查看", icon = "warning" },
{ entity = "task", filter = "status != 'done' && status != 'cancelled'", sort = "due_date asc", label_field = "title", subtitle_field = "due_date", action = "处理", icon = "clock" },
{ entity = "contract", filter = "status == 'active'", sort = "end_date asc", label_field = "title", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "opportunity", filter = "next_follow_up <= today", label_field = "title", subtitle_field = "next_follow_up", action = "跟进", icon = "phone" }
]
# ── 商机漏斗 ──
[[ui.pages.widgets]]
type = "funnel"
label = "商机漏斗"
entity = "opportunity"
lane_field = "stage"
value_field = "estimated_amount"
lane_order = ["visit", "requirement", "quote", "negotiation", "won", "lost"]
# ── 活跃项目卡片 ──
[[ui.pages.widgets]]
type = "card_list"
label = "活跃项目"
entity = "project"
filter = "status == 'in_progress'"
max_items = 4
title_field = "name"
subtitle_field = "contract_amount"
tags = ["business_type", "status"]
```
**依赖:** 数据源来自平台已有的聚合 API`/count``/aggregate`。Filter 表达式使用平台过滤 DSL`==`, `!=`, `||`, `&&`, `<=`)。
---
## 4. Layer 3: 专业输出 — freelance 插件
一键生成专业 PDF替代手动排 Word。
> **模板引擎说明:**
> - 语法基于 Handlebars`{{field}}`, `{{#each relation}}...{{/each}}`
> - 当前实体字段直接可用:`{{amount}}`, `{{status}}`
> - 关系字段解析:`{{client.name}}` 表示通过 `client_id` 引用的 client 实体的 name 字段,渲染器需自动解析
> - `{{#each lines}}` 用于一对多关系(如 quote → quote_line渲染器查询子实体并遍历
> - 平台需要实现 PDF 渲染管道TOML 模板 → Handlebars 渲染(注入数据)→ HTML → wkhtmltopdf/浏览器打印 → PDF
### 4.1 报价单模板
```toml
[[templates]]
name = "quote_pdf"
display_name = "报价单"
entity = "quote"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; border-bottom: 2px solid #333; padding-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f5f5f5; }
.total { text-align: right; font-size: 18px; font-weight: bold; }
.footer { margin-top: 40px; color: #666; font-size: 12px; }
</style></head>
<body>
<h1>报价单 {{quote_number}}</h1>
<p>客户:{{client.name}} | 有效期至:{{valid_until}}</p>
<table>
<tr><th>项目</th><th>描述</th><th>数量</th><th>单价</th><th>金额</th></tr>
{{#each lines}}
<tr><td>{{item_name}}</td><td>{{description}}</td><td>{{quantity}}</td><td>{{unit_price}}</td><td>{{amount}}</td></tr>
{{/each}}
</table>
<p class="total">小计:{{subtotal}} | 税率:{{tax_rate}}% | 总计:{{total_amount}}</p>
<div class="footer">备注:{{notes}}</div>
</body>
</html>
"""
```
### 4.2 发票模板
```toml
[[templates]]
name = "invoice_pdf"
display_name = "发票"
entity = "invoice"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; color: #1890ff; border-bottom: 2px solid #1890ff; padding-bottom: 10px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
.info-item { padding: 8px; background: #fafafa; }
.amount { font-size: 24px; font-weight: bold; text-align: center; color: #f5222d; margin: 20px 0; }
.status-badge { display: inline-block; padding: 4px 12px; border-radius: 4px; background: #f0f0f0; }
</style></head>
<body>
<h1>发票 {{invoice_number}}</h1>
<div class="info-grid">
<div class="info-item">客户:{{client.name}}</div>
<div class="info-item">类型:{{type}}</div>
<div class="info-item">开票日期:{{issue_date}}</div>
<div class="info-item">到期日:{{due_date}}</div>
</div>
<div class="amount">¥{{amount}}</div>
<p>状态:<span class="status-badge">{{status}}</span></p>
<p>备注:{{notes}}</p>
</body>
</html>
"""
```
### 4.3 合同模板
```toml
[[templates]]
name = "contract_pdf"
display_name = "合同"
entity = "contract"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; border-bottom: 3px double #333; padding-bottom: 10px; }
.parties { margin: 20px 0; padding: 15px; background: #fafafa; border-left: 4px solid #1890ff; }
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
</style></head>
<body>
<h1>{{title}}</h1>
<p>合同编号:{{contract_number}}</p>
<div class="parties">
<p>甲方:{{client.name}}</p>
<p>合同金额:¥{{amount}} | 已付:¥{{paid_amount}}</p>
<p>期限:{{start_date}} 至 {{end_date}}</p>
<p>付款条款:{{payment_terms}}</p>
</div>
<p>备注:{{notes}}</p>
<div class="signature">
<div class="sig-box">甲方签章</div>
<div class="sig-box">乙方签章</div>
</div>
</body>
</html>
"""
```
---
## 5. itops 插件增强
### 5.1 Settings
```toml
[settings]
[[settings.fields]]
name = "default_sla_response"
display_name = "默认SLA响应时间(小时)"
field_type = "number"
default_value = 8
range = [1.0, 72.0]
group = "SLA"
[[settings.fields]]
name = "default_sla_resolve"
display_name = "默认SLA解决时间(小时)"
field_type = "number"
default_value = 48
range = [1.0, 168.0]
group = "SLA"
[[settings.fields]]
name = "notify_sla_breach"
display_name = "SLA超标提醒"
field_type = "boolean"
default_value = true
group = "提醒"
[[settings.fields]]
name = "notify_check_due"
display_name = "巡检到期提醒"
field_type = "boolean"
default_value = true
group = "提醒"
```
### 5.2 Trigger Events
```toml
[[trigger_events]]
name = "ticket_created"
display_name = "新工单"
description = "创建工单时开始SLA计时并通知"
entity = "ticket"
on = "create"
[[trigger_events]]
name = "ticket_status_changed"
display_name = "工单状态变更"
description = "工单状态变化时检查SLA是否达标"
entity = "ticket"
on = "update"
[[trigger_events]]
name = "contract_status_changed"
display_name = "维保合同状态变更"
description = "合同状态变化时检查到期预警"
entity = "service_contract"
on = "update"
[[trigger_events]]
name = "check_plan_updated"
display_name = "巡检计划更新"
description = "巡检计划更新时检查下次巡检日期"
entity = "check_plan"
on = "update"
```
### 5.3 Cascade
**已有字段追加 cascade 属性**,不是新增字段:
**ticket 实体 — 已有 contract_id 字段追加:**
```toml
cascade_from = "client_id"
cascade_filter = "client_id"
```
**check_record 实体 — 已有 contract_id 字段追加:**
```toml
cascade_from = "plan_id"
cascade_filter = "contract_id"
```
### 5.4 Visible When
**已有字段追加 visible_when 属性**
**ticket 实体 — 已有 resolution 字段追加:**
```toml
visible_when = "status == 'resolved' || status == 'closed'"
```
**ticket 实体 — 已有 responded_at 字段追加:**
```toml
visible_when = "status != 'open'"
```
**ticket 实体 — 已有 resolved_at 字段追加:**
```toml
visible_when = "status == 'resolved' || status == 'closed'"
```
**ticket 实体 — 已有 closed_at 字段追加:**
```toml
visible_when = "status == 'closed'"
```
**check_record 实体 — 已有 issues_found 字段追加:**
```toml
visible_when = "result == 'abnormal'"
```
**check_record 实体 — 已有 actions_taken 字段追加:**
```toml
visible_when = "result == 'abnormal'"
```
### 5.5 Validation
**已有字段追加 validation 属性**
**service_contract 实体 — 已有 contract_number 字段追加:**
```toml
validation = { pattern = "^SC-\\d{4}-\\d{4}$", message = "格式SC-YYYY-NNNN" }
```
### 5.6 Dashboard
> **同 Layer 2 说明:** widgets 需要平台层配合manifest.rs 扩展 + 前端渲染),非纯 plugin.toml 改动。此仪表盘页面**插入到现有页面列表最前面**,现有 4 个页面保持不变。
```toml
[[ui.pages]]
type = "dashboard"
label = "运维概览"
icon = "DashboardOutlined"
[[ui.pages.widgets]]
type = "stat_cards"
label = "运维概览"
cards = [
{ entity = "service_contract", aggregate = "count", filter = "status == 'active'", label = "活跃合同", icon = "file-text", color = "blue" },
{ entity = "ticket", aggregate = "count", filter = "status == 'open' || status == 'in_progress'", label = "待处理工单", icon = "tool", color = "orange" },
{ entity = "ticket", aggregate = "count", filter = "status == 'resolved'", label = "已解决工单", icon = "check-circle", color = "green" },
{ entity = "check_plan", aggregate = "count", filter = "status == 'active'", label = "活跃巡检", icon = "schedule", color = "blue" }
]
[[ui.pages.widgets]]
type = "action_list"
label = "紧急待办"
max_items = 5
queries = [
{ entity = "ticket", filter = "status == 'open'", sort = "priority asc", label_field = "title", subtitle_field = "type", action = "处理", icon = "warning" },
{ entity = "service_contract", filter = "status == 'active'", sort = "end_date asc", label_field = "name", subtitle_field = "end_date", action = "续约", icon = "file-text" },
{ entity = "check_plan", filter = "status == 'active'", sort = "next_check_date asc", label_field = "name", subtitle_field = "next_check_date", action = "巡检", icon = "schedule" }
]
```
### 5.7 Template维保合同 PDF
```toml
[[templates]]
name = "service_contract_pdf"
display_name = "维保合同"
entity = "service_contract"
format = "pdf"
template_html = """
<html>
<head><style>
body { font-family: 'Microsoft YaHei', sans-serif; margin: 40px; }
h1 { text-align: center; border-bottom: 3px double #1890ff; padding-bottom: 10px; color: #1890ff; }
.sla-box { margin: 20px 0; padding: 15px; background: #e6f7ff; border: 1px solid #91d5ff; border-radius: 4px; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 20px 0; }
.info-item { padding: 8px; background: #fafafa; }
.signature { margin-top: 60px; display: grid; grid-template-columns: 1fr 1fr; gap: 40px; }
.sig-box { border-top: 1px solid #333; padding-top: 10px; text-align: center; }
</style></head>
<body>
<h1>{{name}}</h1>
<p>合同编号:{{contract_number}}</p>
<div class="info-grid">
<div class="info-item">客户:{{client.name}}</div>
<div class="info-item">合同金额:¥{{amount}}</div>
<div class="info-item">期限:{{start_date}} 至 {{end_date}}</div>
<div class="info-item">状态:{{status}}</div>
</div>
<div class="sla-box">
<strong>SLA 承诺:</strong>响应 {{sla_response_hours}} 小时内 / 解决 {{sla_resolve_hours}} 小时内
</div>
<p>服务范围:{{service_scope}}</p>
<p>付款条款:{{payment_terms}}</p>
<p>备注:{{notes}}</p>
<div class="signature">
<div class="sig-box">甲方签章</div>
<div class="sig-box">乙方签章</div>
</div>
</body>
</html>
"""
```
---
## 6. 改进汇总
| 层次 | 能力 | freelance | itops |
|------|------|-----------|-------|
| Layer 1 | settings | 7 个配置项(公司名/税率/提醒偏好) | 4 个配置项SLA默认值/提醒偏好) |
| Layer 1 | trigger_events | 5 个事件(商机/合同/发票/任务/支出) | 4 个事件(工单/合同/巡检) |
| Layer 1 | cascade | 4 处联动(合同/发票/工时表单) | 2 处联动(工单/巡检记录) |
| Layer 1 | visible_when | 4 个条件字段 | 6 个条件字段 |
| Layer 1 | validation | 2 个校验(邮箱/手机) | 1 个校验(合同编号格式) |
| Layer 2 | dashboard widgets | 财务卡片+紧急待办+商机漏斗+项目卡片 | 运维卡片+紧急待办 |
| Layer 3 | templates | 3 个 PDF报价单/发票/合同) | 1 个 PDF维保合同 |
**总计:** 2 个插件 × 3 层增强,从「数据录入」升级为「赚钱工具」。
---
## 7. 实施优先级
```
P1: freelance Layer 1settings + trigger_events + cascade + visible_when + validation
P2: itops Layer 1settings + trigger_events + cascade + visible_when + validation
P3: freelance Layer 33 个 PDF 模板)
P4: itops Layer 3维保合同 PDF 模板)
P5: freelance Layer 2仪表盘 widgets
P6: itops Layer 2仪表盘 widgets
```
P1-P4 是纯 plugin.toml 改动(给已有字段追加 cascade/visible_when/validation 属性,以及新增 settings/trigger_events/templates 段落可立即实施。P5-P6 的仪表盘 widgets 需要平台层配合:扩展 `manifest.rs``PluginPageType::Dashboard` 支持 `widgets` 字段 + 前端渲染组件。