Commit 1e3c2cf0 authored by 375242562@qq.com's avatar 375242562@qq.com

feat: 临床试验管理支持快速变更试验状态

后端:新增 PATCH /trials/{id}/status 接口,校验状态值合法性
前端:
- trialService 新增 updateStatus() 方法
- TrialDataGrid 操作列新增状态变更按钮(SwapHorizIcon)
  点击弹出 Menu,列出4种状态(招募中/已关闭/已完成/已暂停)
  当前状态自动禁用,选中新状态后立即调用接口更新
- TrialsPage 处理 onStatusChange,乐观更新列表和详情面板状态
Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 79992c2e
......@@ -168,6 +168,28 @@ async def update_trial(trial_id: str, data: TrialCreate, db: AsyncSession = Depe
return _trial_to_response(trial, criteria)
@router.patch("/{trial_id}/status", response_model=TrialResponse)
async def update_trial_status(
trial_id: str,
payload: dict,
db: AsyncSession = Depends(get_db),
):
from app.models.trial import TrialStatus
trial = await db.get(Trial, trial_id)
if not trial:
raise HTTPException(status_code=404, detail="Trial not found")
new_status = payload.get("status")
if new_status not in [s.value for s in TrialStatus]:
raise HTTPException(status_code=422, detail=f"Invalid status: {new_status}")
trial.status = TrialStatus(new_status)
await db.commit()
await db.refresh(trial)
crit_result = await db.execute(
select(Criterion).where(Criterion.trial_id == trial_id).order_by(Criterion.order_index)
)
return _trial_to_response(trial, crit_result.scalars().all())
@router.delete("/{trial_id}", status_code=204)
async def delete_trial(trial_id: str, db: AsyncSession = Depends(get_db)):
trial = await db.get(Trial, trial_id)
......
import { useState } from 'react'
import { DataGrid, GridColDef, GridRowParams } from '@mui/x-data-grid'
import { Chip, Box, Stack, IconButton } from '@mui/material'
import { Chip, Box, Stack, IconButton, Menu, MenuItem, ListItemIcon, Typography, Divider } from '@mui/material'
import EditIcon from '@mui/icons-material/Edit'
import DeleteIcon from '@mui/icons-material/Delete'
import SwapHorizIcon from '@mui/icons-material/SwapHoriz'
import type { TrialResponse } from '../../types/trial'
import { StatusChip } from '../shared/StatusChip'
import { useAuth } from '../../contexts/AuthContext'
type TrialStatus = 'recruiting' | 'closed' | 'completed' | 'suspended'
const STATUS_OPTIONS: { value: TrialStatus; label: string; color: string }[] = [
{ value: 'recruiting', label: '招募中', color: '#2e7d32' },
{ value: 'closed', label: '已关闭', color: '#757575' },
{ value: 'completed', label: '已完成', color: '#1976d2' },
{ value: 'suspended', label: '已暂停', color: '#ed6c02' },
]
const phaseColor: Record<string, 'default' | 'primary' | 'secondary' | 'warning'> = {
I: 'default', II: 'primary', III: 'secondary', IV: 'warning',
}
......@@ -16,13 +27,31 @@ interface TrialDataGridProps {
onRowClick: (row: TrialResponse) => void
onEdit: (row: TrialResponse) => void
onDelete: (id: string) => void
onStatusChange: (id: string, status: string) => void
}
export function TrialDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: TrialDataGridProps) {
export function TrialDataGrid({ rows, loading, onRowClick, onEdit, onDelete, onStatusChange }: TrialDataGridProps) {
const { hasPermission } = useAuth()
const canEdit = hasPermission('btn:trial:edit')
const canDelete = hasPermission('btn:trial:delete')
const [menuAnchor, setMenuAnchor] = useState<HTMLElement | null>(null)
const [menuRow, setMenuRow] = useState<TrialResponse | null>(null)
const handleStatusMenuOpen = (e: React.MouseEvent<HTMLElement>, row: TrialResponse) => {
e.stopPropagation()
setMenuAnchor(e.currentTarget)
setMenuRow(row)
}
const handleStatusSelect = (status: TrialStatus) => {
if (menuRow && status !== menuRow.status) {
onStatusChange(menuRow.id, status)
}
setMenuAnchor(null)
setMenuRow(null)
}
const baseColumns: GridColDef[] = [
{ field: 'nct_number', headerName: 'NCT编号', width: 130 },
{ field: 'title', headerName: '试验名称', flex: 1, minWidth: 200 },
......@@ -44,12 +73,22 @@ export function TrialDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: T
const actionsColumn: GridColDef = {
field: 'actions',
headerName: '操作',
width: 100,
width: 130,
sortable: false,
renderCell: (params) => (
<Stack direction="row" spacing={1} sx={{ height: '100%', alignItems: 'center' }}>
<Stack direction="row" spacing={0.5} sx={{ height: '100%', alignItems: 'center' }}>
{canEdit && (
<IconButton size="small" color="primary" onClick={(e) => {
<IconButton
size="small"
color="primary"
title="变更状态"
onClick={(e) => handleStatusMenuOpen(e, params.row as TrialResponse)}
>
<SwapHorizIcon fontSize="small" />
</IconButton>
)}
{canEdit && (
<IconButton size="small" color="default" onClick={(e) => {
e.stopPropagation()
onEdit(params.row as TrialResponse)
}}>
......@@ -71,6 +110,7 @@ export function TrialDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: T
const columns = (canEdit || canDelete) ? [...baseColumns, actionsColumn] : baseColumns
return (
<>
<DataGrid
rows={rows.map(r => ({ ...r, id: r.id }))}
columns={columns}
......@@ -86,5 +126,34 @@ export function TrialDataGrid({ rows, loading, onRowClick, onEdit, onDelete }: T
'& .MuiDataGrid-cell': { display: 'flex', alignItems: 'center' }
}}
/>
{/* 状态变更菜单 */}
<Menu
anchorEl={menuAnchor}
open={!!menuAnchor}
onClose={() => { setMenuAnchor(null); setMenuRow(null) }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<Typography variant="caption" color="text.secondary" sx={{ px: 2, py: 0.5, display: 'block' }}>
变更试验状态
</Typography>
<Divider />
{STATUS_OPTIONS.map(opt => (
<MenuItem
key={opt.value}
selected={menuRow?.status === opt.value}
disabled={menuRow?.status === opt.value}
onClick={() => handleStatusSelect(opt.value)}
sx={{ minWidth: 140 }}
>
<ListItemIcon>
<Box sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: opt.color }} />
</ListItemIcon>
<Typography variant="body2">{opt.label}</Typography>
</MenuItem>
))}
</Menu>
</>
)
}
......@@ -68,6 +68,16 @@ export function TrialsPage() {
}
}
const handleStatusChange = async (id: string, status: string) => {
try {
const updated = await trialService.updateStatus(id, status)
setTrials(prev => prev.map(t => t.id === id ? { ...t, status: updated.status } : t))
if (selectedForDetail?.id === id) setSelectedForDetail(prev => prev ? { ...prev, status: updated.status } : prev)
} catch {
alert('状态更新失败')
}
}
const handleEdit = (row: TrialResponse) => {
setSelectedForEdit(row)
setAddDialogOpen(true)
......@@ -126,6 +136,7 @@ export function TrialsPage() {
onRowClick={handleRowClick}
onEdit={handleEdit}
onDelete={handleDeleteClick}
onStatusChange={handleStatusChange}
/>
</Box>
)}
......
......@@ -14,6 +14,9 @@ export const trialService = {
update: (id: string, data: TrialCreate) =>
api.put<TrialResponse>(`/trials/${id}`, data).then(r => r.data),
updateStatus: (id: string, status: string) =>
api.patch<TrialResponse>(`/trials/${id}/status`, { status }).then(r => r.data),
delete: (id: string) =>
api.delete(`/trials/${id}`),
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment