feat(web): comprehensive frontend performance and UI/UX optimization

Performance improvements:
- Vite build: manual chunks, terser minification, optimizeDeps
- API response caching with 5s TTL via axios interceptors
- React.memo for SidebarMenuItem, useCallback for handlers
- CSS classes replacing inline styles to reduce reflows

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

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

SEO: meta theme-color, format-detection, robots.txt
This commit is contained in:
iven
2026-04-13 01:37:55 +08:00
parent 88f6516fa9
commit e16c1a85d7
34 changed files with 3558 additions and 778 deletions

View File

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

View File

@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { Button, message, Modal, Table, Tag } from 'antd';
import { useEffect, useCallback, useState } from 'react';
import { Button, message, Modal, Table, Tag, theme } from 'antd';
import { EyeOutlined, PauseCircleOutlined, PlayCircleOutlined, StopOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import {
listInstances,
@@ -11,11 +12,11 @@ import {
import { getProcessDefinition, type NodeDef, type EdgeDef } from '../../api/workflowDefinitions';
import ProcessViewer from './ProcessViewer';
const statusColors: Record<string, string> = {
running: 'processing',
suspended: 'warning',
completed: 'green',
terminated: 'red',
const statusStyles: Record<string, { bg: string; color: string; text: string }> = {
running: { bg: '#EEF2FF', color: '#4F46E5', text: '运行中' },
suspended: { bg: '#FFFBEB', color: '#D97706', text: '已挂起' },
completed: { bg: '#ECFDF5', color: '#059669', text: '已完成' },
terminated: { bg: '#FEF2F2', color: '#DC2626', text: '已终止' },
};
export default function InstanceMonitor() {
@@ -24,12 +25,13 @@ export default function InstanceMonitor() {
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
// ProcessViewer state
const [viewerOpen, setViewerOpen] = useState(false);
const [viewerNodes, setViewerNodes] = useState<NodeDef[]>([]);
const [viewerEdges, setViewerEdges] = useState<EdgeDef[]>([]);
const [activeNodeIds, setActiveNodeIds] = useState<string[]>([]);
const [viewerLoading, setViewerLoading] = useState(false);
const { token } = theme.useToken();
const isDark = token.colorBgContainer === '#111827' || token.colorBgContainer === 'rgb(17, 24, 39)';
const fetchData = useCallback(async () => {
setLoading(true);
@@ -109,54 +111,127 @@ export default function InstanceMonitor() {
};
const columns: ColumnsType<ProcessInstanceInfo> = [
{ title: '流程', dataIndex: 'definition_name', key: 'definition_name' },
{ title: '业务键', dataIndex: 'business_key', key: 'business_key' },
{
title: '状态', dataIndex: 'status', key: 'status', width: 100,
render: (s: string) => <Tag color={statusColors[s]}>{s}</Tag>,
title: '流程',
dataIndex: 'definition_name',
key: 'definition_name',
render: (v: string) => <span style={{ fontWeight: 500 }}>{v}</span>,
},
{ title: '当前节点', key: 'current_nodes', width: 150,
{
title: '业务键',
dataIndex: 'business_key',
key: 'business_key',
render: (v: string | undefined) => v || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (s: string) => {
const info = statusStyles[s] || { bg: '#F1F5F9', color: '#64748B', text: s };
return (
<Tag style={{
background: info.bg,
border: 'none',
color: info.color,
fontWeight: 500,
}}>
{info.text}
</Tag>
);
},
},
{
title: '当前节点',
key: 'current_nodes',
width: 150,
render: (_, record) => record.active_tokens.map(t => t.node_id).join(', ') || '-',
},
{ title: '发起时间', dataIndex: 'started_at', key: 'started_at', width: 180,
render: (v: string) => new Date(v).toLocaleString(),
{
title: '发起时间',
dataIndex: 'started_at',
key: 'started_at',
width: 180,
render: (v: string) => (
<span style={{ color: isDark ? '#64748B' : '#94A3B8', fontSize: 13 }}>
{new Date(v).toLocaleString()}
</span>
),
},
{
title: '操作', key: 'action', width: 220,
title: '操作',
key: 'action',
width: 240,
render: (_, record) => (
<>
<Button size="small" onClick={() => handleViewFlow(record)} style={{ marginRight: 8 }}>
<div style={{ display: 'flex', gap: 4 }}>
<Button
size="small"
type="text"
icon={<EyeOutlined />}
onClick={() => handleViewFlow(record)}
>
</Button>
{record.status === 'running' && (
<>
<Button size="small" onClick={() => handleSuspend(record.id)} style={{ marginRight: 8 }}>
<Button
size="small"
type="text"
icon={<PauseCircleOutlined />}
onClick={() => handleSuspend(record.id)}
>
</Button>
<Button size="small" danger onClick={() => handleTerminate(record.id)}>
<Button
size="small"
type="text"
danger
icon={<StopOutlined />}
onClick={() => handleTerminate(record.id)}
>
</Button>
</>
)}
{record.status === 'suspended' && (
<Button size="small" type="primary" onClick={() => handleResume(record.id)}>
<Button
size="small"
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => handleResume(record.id)}
>
</Button>
)}
</>
</div>
),
},
];
return (
<>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{ current: page, total, pageSize: 20, onChange: setPage }}
/>
<div style={{
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 12,
border: `1px solid ${isDark ? '#1E293B' : '#F1F5F9'}`,
overflow: 'hidden',
}}>
<Table
rowKey="id"
columns={columns}
dataSource={data}
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t} 条记录`,
}}
/>
</div>
<Modal
title="流程图查看"
open={viewerOpen}

View File

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

View File

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