Files
hms/apps/web/src/pages/PluginKanbanPage.tsx
iven 83fe89cbcd
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
fix: 全系统审计问题修复 — 安全/数据完整性/功能缺陷/UX (Phase 1-5)
Phase 1 安全热修复:
- P0-1: /uploads 文件服务添加 JWT 认证中间件(支持 header + query param)
- P0-2: analytics/batch 路由从 public 移到 protected_routes
- P0-3: plugin engine SQL 注入修复(format! → 参数化查询)
- P0-new: stats_service compute_avg_field 字段白名单 + FLOAT8 类型转换

Phase 2 数据完整性:
- P0-4: 组织删除级联检查(添加部门存在性校验)
- P0-5: 部门删除级联检查(添加岗位 + 用户存在性校验)
- P0-8: workflow on_tenant_deleted 实现 5 实体批量删除
- P0-7: 并行网关 race condition 修复(consumed → completed 原子转换)

Phase 3 P1 后端 Bug:
- P1-12: plugin host 表名消毒(使用 sanitize_identifier)
- P1-10: workflow deprecated 状态转换(published → deprecated)
- P1-11: workflow 更新验证条件(nodes/edges 任一变化即验证)
- P0-9: 小程序 .gitignore 添加 .env/.env.*/日志
- P1-19: 小程序加密密钥替换为 64 字符强密钥

Phase 4 消息模块:
- P1-5: 通知偏好 GET 路由 + handler
- P1-4: 消息模板 update/delete CRUD + version
- P2-8: mark_all_read SQL 添加 version + 1
- P2-7: markAsRead 改为乐观更新 + 失败回滚

Phase 5 前端修复:
- P2-9: 通知面板点击导航到 /messages
- P2-1: 随访任务患者名批量 ID 解析(替代 UUID 显示)
- P2-5: AppointmentList 分离 patient_id/doctor_id 分别调用 API
- P2-17: PluginMarket installed 字段修正(name → id)
- P3-3: 路由标题 fallback 改为模式匹配(支持 :id 动态路径)
- P2-15: workflow updateDefinition 添加 version 字段
- P3-9: Kanban 版本使用记录实际 version
- P2-21: secure-storage 生产环境无密钥时阻止存储
- P3-11: destroyOnHidden → destroyOnClose
- P3-13: PendingTasks 深色模式 Tag 颜色适配

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 19:16:23 +08:00

349 lines
9.3 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 = '';
let currentRecord: Record<string, any> | null = null;
for (const [lane, items] of Object.entries(lanes)) {
const found = items.find((item) => item.id === recordId);
if (found) {
currentLane = lane;
currentRecord = found;
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: currentRecord?.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}
/>
);
}