- EntitySelect: 未使用的 searchFields 改为 _searchFields - PluginKanbanPage: DragEndEvent/DragStartEvent 改为 type import, lane_order 改为 optional - PluginDashboardPage: 添加 PluginPageSchema import, 移除未使用的 CHART_COLORS/palette/totalCount - PluginGraphPage: 移除未使用的 Title/textColor, 修复 hovered → hoverState
346 lines
9.2 KiB
TypeScript
346 lines
9.2 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { Card, Spin, Typography, Tag, message } from 'antd';
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
closestCorners,
|
|
} from '@dnd-kit/core';
|
|
import type { DragEndEvent, DragStartEvent } 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}
|
|
/>
|
|
);
|
|
}
|