From c9a58e9d34adf8662cf2f1cdc7bdd7a95703216b Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 17 Apr 2026 11:00:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20Kanban=20=E7=9C=8B=E6=9D=BF?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=20=E2=80=94=20dnd-kit=20=E6=8B=96=E6=8B=BD?= =?UTF-8?q?=20+=20=E8=B7=A8=E5=88=97=E7=A7=BB=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PluginKanbanPage 看板页面,支持 dnd-kit 拖拽 - 支持泳道分组、卡片标题/副标题/标签展示 - 乐观更新 UI,失败自动回滚 - 路由入口 /plugins/:pluginId/kanban/:entityName 自加载 schema - PluginTabsPage 新增 kanban 页面类型支持 - PluginStore 新增 kanban 菜单项和路由生成 - 安装 @dnd-kit/core + @dnd-kit/sortable --- apps/web/package.json | 2 + apps/web/pnpm-lock.yaml | 56 +++- apps/web/src/App.tsx | 2 + apps/web/src/pages/PluginKanbanPage.tsx | 346 ++++++++++++++++++++++++ apps/web/src/pages/PluginTabsPage.tsx | 9 + apps/web/src/stores/plugin.ts | 11 +- 6 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/pages/PluginKanbanPage.tsx diff --git a/apps/web/package.json b/apps/web/package.json index b942b4a..6a196e8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 07abf07..6134b6e 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -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: diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5e0c2f2..2e4d953 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/apps/web/src/pages/PluginKanbanPage.tsx b/apps/web/src/pages/PluginKanbanPage.tsx new file mode 100644 index 0000000..2de02cb --- /dev/null +++ b/apps/web/src/pages/PluginKanbanPage.tsx @@ -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>({}); + const [loading, setLoading] = useState(true); + const [activeId, setActiveId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + ); + + const fetchData = async () => { + setLoading(true); + try { + const allData: Record = {}; + 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 = {}; + 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 ( +
+ +
+ ); + } + + return ( + +
+ {laneOrder.map((lane) => { + const items = lanes[lane] || []; + return ( +
+
+ {lane} + {items.length} +
+
+ {items.map((item) => ( + + + {item.data?.[cardTitleField] ?? '-'} + + {cardSubtitleField && item.data?.[cardSubtitleField] && ( +
+ + {item.data[cardSubtitleField]} + +
+ )} + {cardFields && ( +
+ {cardFields.map( + (f) => + item.data?.[f] ? ( + {String(item.data[f])} + ) : null, + )} +
+ )} +
+ ))} +
+
+ ); + })} +
+ + {activeCard ? ( + + + {activeCard.data?.[cardTitleField] ?? '-'} + + {cardSubtitleField && activeCard.data?.[cardSubtitleField] && ( +
+ + {activeCard.data[cardSubtitleField]} + +
+ )} +
+ ) : null} +
+
+ ); +} + +// ── 路由入口:自加载 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 ( +
+ +
+ ); + } + + if (!pageConfig) { + return
未找到看板页面配置
; + } + + return ( + + ); +} + +// ── 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 ( + + ); +} diff --git a/apps/web/src/pages/PluginTabsPage.tsx b/apps/web/src/pages/PluginTabsPage.tsx index db90cd6..2af22ec 100644 --- a/apps/web/src/pages/PluginTabsPage.tsx +++ b/apps/web/src/pages/PluginTabsPage.tsx @@ -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 ( + + ); + } return
不支持的页面类型: {tab.type}
; }; diff --git a/apps/web/src/stores/plugin.ts b/apps/web/src/stores/plugin.ts index adf36c0..530ee13 100644 --- a/apps/web/src/stores/plugin.ts +++ b/apps/web/src/stores/plugin.ts @@ -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((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 类型不生成菜单项 }