feat(web): Kanban 看板页面 — dnd-kit 拖拽 + 跨列移动
- 新增 PluginKanbanPage 看板页面,支持 dnd-kit 拖拽 - 支持泳道分组、卡片标题/副标题/标签展示 - 乐观更新 UI,失败自动回滚 - 路由入口 /plugins/:pluginId/kanban/:entityName 自加载 schema - PluginTabsPage 新增 kanban 页面类型支持 - PluginStore 新增 kanban 菜单项和路由生成 - 安装 @dnd-kit/core + @dnd-kit/sortable
This commit is contained in:
@@ -11,6 +11,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"antd": "^6.3.5",
|
||||
"axios": "^1.15.0",
|
||||
|
||||
56
apps/web/pnpm-lock.yaml
generated
56
apps/web/pnpm-lock.yaml
generated
@@ -11,6 +11,12 @@ importers:
|
||||
'@ant-design/icons':
|
||||
specifier: ^6.1.1
|
||||
version: 6.1.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
|
||||
'@xyflow/react':
|
||||
specifier: ^12.10.2
|
||||
version: 12.10.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@@ -184,6 +190,28 @@ packages:
|
||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
|
||||
|
||||
@@ -1871,6 +1899,31 @@ snapshots:
|
||||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.5)':
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@19.2.5)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.5)
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.5)
|
||||
react: 19.2.5
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@19.2.5)':
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
tslib: 2.8.1
|
||||
|
||||
'@emnapi/core@1.9.2':
|
||||
dependencies:
|
||||
'@emnapi/wasi-threads': 1.2.1
|
||||
@@ -3284,8 +3337,7 @@ snapshots:
|
||||
dependencies:
|
||||
typescript: 6.0.2
|
||||
|
||||
tslib@2.8.1:
|
||||
optional: true
|
||||
tslib@2.8.1: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
|
||||
@@ -20,6 +20,7 @@ const PluginTabsPage = lazy(() => import('./pages/PluginTabsPage').then((m) => (
|
||||
const PluginTreePage = lazy(() => import('./pages/PluginTreePage').then((m) => ({ default: m.PluginTreePage })));
|
||||
const PluginGraphPage = lazy(() => import('./pages/PluginGraphPage').then((m) => ({ default: m.PluginGraphPage })));
|
||||
const PluginDashboardPage = lazy(() => import('./pages/PluginDashboardPage').then((m) => ({ default: m.PluginDashboardPage })));
|
||||
const PluginKanbanPage = lazy(() => import('./pages/PluginKanbanPage'));
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
@@ -146,6 +147,7 @@ export default function App() {
|
||||
<Route path="/plugins/:pluginId/tree/:entityName" element={<PluginTreePage />} />
|
||||
<Route path="/plugins/:pluginId/graph/:entityName" element={<PluginGraphPage />} />
|
||||
<Route path="/plugins/:pluginId/dashboard" element={<PluginDashboardPage />} />
|
||||
<Route path="/plugins/:pluginId/kanban/:entityName" element={<PluginKanbanPage />} />
|
||||
<Route path="/plugins/:pluginId/:entityName" element={<PluginCRUDPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
346
apps/web/src/pages/PluginKanbanPage.tsx
Normal file
346
apps/web/src/pages/PluginKanbanPage.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card, Spin, Typography, Tag, message } from 'antd';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
closestCorners,
|
||||
} from '@dnd-kit/core';
|
||||
import { listPluginData, patchPluginData } from '../api/pluginData';
|
||||
import { getPluginSchema, type PluginPageSchema } from '../api/plugins';
|
||||
|
||||
// ── 内部看板渲染组件 ──
|
||||
|
||||
interface KanbanInnerProps {
|
||||
pluginId: string;
|
||||
entity: string;
|
||||
laneField: string;
|
||||
laneOrder: string[];
|
||||
cardTitleField: string;
|
||||
cardSubtitleField?: string;
|
||||
cardFields?: string[];
|
||||
enableDrag?: boolean;
|
||||
}
|
||||
|
||||
function KanbanInner({
|
||||
pluginId,
|
||||
entity,
|
||||
laneField,
|
||||
laneOrder,
|
||||
cardTitleField,
|
||||
cardSubtitleField,
|
||||
cardFields,
|
||||
enableDrag,
|
||||
}: KanbanInnerProps) {
|
||||
const [lanes, setLanes] = useState<Record<string, any[]>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const allData: Record<string, any[]> = {};
|
||||
const results = await Promise.all(
|
||||
laneOrder.map(async (lane) => {
|
||||
const res = await listPluginData(pluginId, entity, 1, 100, {
|
||||
filter: { [laneField]: lane },
|
||||
});
|
||||
return { lane, data: res.data || [] };
|
||||
}),
|
||||
);
|
||||
for (const { lane, data } of results) {
|
||||
allData[lane] = data;
|
||||
}
|
||||
setLanes(allData);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [pluginId, entity]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
if (!enableDrag) return;
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const recordId = active.id as string;
|
||||
const newLane = String(over.data.current?.lane || over.id);
|
||||
if (!newLane) return;
|
||||
|
||||
let currentLane = '';
|
||||
for (const [lane, items] of Object.entries(lanes)) {
|
||||
if (items.some((item) => item.id === recordId)) {
|
||||
currentLane = lane;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (currentLane === newLane) return;
|
||||
|
||||
// 乐观更新
|
||||
setLanes((prev) => {
|
||||
const next: Record<string, any[]> = {};
|
||||
for (const [lane, items] of Object.entries(prev)) {
|
||||
if (lane === currentLane) {
|
||||
next[lane] = items.filter((item) => item.id !== recordId);
|
||||
} else if (lane === newLane) {
|
||||
const moved = prev[currentLane]?.find((item) => item.id === recordId);
|
||||
next[lane] = moved ? [...items, moved] : [...items];
|
||||
} else {
|
||||
next[lane] = items;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
await patchPluginData(pluginId, entity, recordId, {
|
||||
data: { [laneField]: newLane },
|
||||
version: 0,
|
||||
});
|
||||
message.success('移动成功');
|
||||
} catch {
|
||||
message.error('移动失败');
|
||||
fetchData();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragCancel = () => {
|
||||
setActiveId(null);
|
||||
};
|
||||
|
||||
const activeCard = activeId
|
||||
? Object.values(lanes)
|
||||
.flat()
|
||||
.find((item) => item.id === activeId)
|
||||
: null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={handleDragCancel}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 16, overflowX: 'auto', padding: 16 }}>
|
||||
{laneOrder.map((lane) => {
|
||||
const items = lanes[lane] || [];
|
||||
return (
|
||||
<div
|
||||
key={lane}
|
||||
id={`lane-${lane}`}
|
||||
style={{
|
||||
minWidth: 280,
|
||||
flex: 1,
|
||||
background: 'var(--colorBgLayout, #f5f5f5)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>{lane}</Typography.Text>
|
||||
<Tag>{items.length}</Tag>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item) => (
|
||||
<Card
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
size="small"
|
||||
style={{
|
||||
cursor: enableDrag ? 'grab' : 'default',
|
||||
opacity: activeId === item.id ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>
|
||||
{item.data?.[cardTitleField] ?? '-'}
|
||||
</Typography.Text>
|
||||
{cardSubtitleField && item.data?.[cardSubtitleField] && (
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{item.data[cardSubtitleField]}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
{cardFields && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{cardFields.map(
|
||||
(f) =>
|
||||
item.data?.[f] ? (
|
||||
<Tag key={f}>{String(item.data[f])}</Tag>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<DragOverlay>
|
||||
{activeCard ? (
|
||||
<Card
|
||||
size="small"
|
||||
style={{
|
||||
cursor: 'grabbing',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
width: 260,
|
||||
}}
|
||||
>
|
||||
<Typography.Text strong>
|
||||
{activeCard.data?.[cardTitleField] ?? '-'}
|
||||
</Typography.Text>
|
||||
{cardSubtitleField && activeCard.data?.[cardSubtitleField] && (
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{activeCard.data[cardSubtitleField]}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 路由入口:自加载 schema ──
|
||||
|
||||
/**
|
||||
* 路由入口组件
|
||||
* 路由: /plugins/:pluginId/kanban/:entityName
|
||||
* 自动加载 schema 并提取 kanban 页面配置
|
||||
*/
|
||||
export default function PluginKanbanPageRoute() {
|
||||
const { pluginId, entityName } = useParams<{
|
||||
pluginId: string;
|
||||
entityName: string;
|
||||
}>();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pageConfig, setPageConfig] = useState<{
|
||||
entity: string;
|
||||
lane_field: string;
|
||||
lane_order: string[];
|
||||
card_title_field: string;
|
||||
card_subtitle_field?: string;
|
||||
card_fields?: string[];
|
||||
enable_drag?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pluginId || !entityName) return;
|
||||
|
||||
async function loadSchema() {
|
||||
try {
|
||||
const schema = await getPluginSchema(pluginId!);
|
||||
const pages: PluginPageSchema[] = schema.ui?.pages || [];
|
||||
const kanbanPage = pages.find(
|
||||
(p): p is PluginPageSchema & { type: 'kanban' } =>
|
||||
p.type === 'kanban' && p.entity === entityName,
|
||||
);
|
||||
if (kanbanPage) {
|
||||
setPageConfig(kanbanPage);
|
||||
}
|
||||
} catch {
|
||||
message.warning('Schema 加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadSchema();
|
||||
}, [pluginId, entityName]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pageConfig) {
|
||||
return <div style={{ padding: 24 }}>未找到看板页面配置</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<KanbanInner
|
||||
pluginId={pluginId!}
|
||||
entity={pageConfig.entity}
|
||||
laneField={pageConfig.lane_field}
|
||||
laneOrder={pageConfig.lane_order || []}
|
||||
cardTitleField={pageConfig.card_title_field}
|
||||
cardSubtitleField={pageConfig.card_subtitle_field}
|
||||
cardFields={pageConfig.card_fields}
|
||||
enableDrag={pageConfig.enable_drag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tabs/Detail 内嵌使用 ──
|
||||
|
||||
export interface PluginKanbanPageFromConfigProps {
|
||||
pluginId: string;
|
||||
page: {
|
||||
entity: string;
|
||||
lane_field: string;
|
||||
lane_order?: string[];
|
||||
card_title_field: string;
|
||||
card_subtitle_field?: string;
|
||||
card_fields?: string[];
|
||||
enable_drag?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function PluginKanbanPageFromConfig({
|
||||
pluginId,
|
||||
page,
|
||||
}: PluginKanbanPageFromConfigProps) {
|
||||
return (
|
||||
<KanbanInner
|
||||
pluginId={pluginId}
|
||||
entity={page.entity}
|
||||
laneField={page.lane_field}
|
||||
laneOrder={page.lane_order || []}
|
||||
cardTitleField={page.card_title_field}
|
||||
cardSubtitleField={page.card_subtitle_field}
|
||||
cardFields={page.card_fields}
|
||||
enableDrag={page.enable_drag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Tabs, Spin, message } from 'antd';
|
||||
import { getPluginSchema, type PluginPageSchema, type PluginSchemaResponse } from '../api/plugins';
|
||||
import PluginCRUDPage from './PluginCRUDPage';
|
||||
import { PluginTreePage } from './PluginTreePage';
|
||||
import { PluginKanbanPageFromConfig } from './PluginKanbanPage';
|
||||
|
||||
/**
|
||||
* 插件 Tabs 页面 — 通过路由参数自加载 schema
|
||||
@@ -65,6 +66,14 @@ export function PluginTabsPage() {
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (tab.type === 'kanban') {
|
||||
return (
|
||||
<PluginKanbanPageFromConfig
|
||||
pluginId={pluginId!}
|
||||
page={tab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <div>不支持的页面类型: {tab.type}</div>;
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface PluginMenuItem {
|
||||
label: string;
|
||||
pluginId: string;
|
||||
entity?: string;
|
||||
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard';
|
||||
pageType: 'crud' | 'tree' | 'tabs' | 'detail' | 'graph' | 'dashboard' | 'kanban';
|
||||
}
|
||||
|
||||
export interface PluginMenuGroup {
|
||||
@@ -128,6 +128,15 @@ export const usePluginStore = create<PluginStore>((set, get) => ({
|
||||
pluginId: plugin.id,
|
||||
pageType: 'dashboard' as const,
|
||||
});
|
||||
} else if (page.type === 'kanban') {
|
||||
items.push({
|
||||
key: `/plugins/${plugin.id}/kanban/${page.entity}`,
|
||||
icon: 'UnorderedListOutlined',
|
||||
label: page.label,
|
||||
pluginId: plugin.id,
|
||||
entity: page.entity,
|
||||
pageType: 'kanban' as const,
|
||||
});
|
||||
}
|
||||
// detail 类型不生成菜单项
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user