rsf-admin/src/page/system/aiParam/AiParamList.jsx
@@ -1,120 +1,258 @@
import React, { useState } from "react";
import React, { useMemo, useState } from "react";
import {
    List,
    DatagridConfigurable,
    SearchInput,
    TopToolbar,
    SelectColumnsButton,
    EditButton,
    FilterButton,
    BulkDeleteButton,
    WrapperField,
    TextField,
    NumberField,
    DateField,
    BooleanField,
    TextInput,
    DateInput,
    List,
    SearchInput,
    SelectInput,
    DeleteButton,
} from 'react-admin';
import { Box } from '@mui/material';
import { styled } from '@mui/material/styles';
import EmptyData from "@/page/components/EmptyData";
import MyCreateButton from "@/page/components/MyCreateButton";
import MyExportButton from '@/page/components/MyExportButton';
import { OPERATE_MODE, DEFAULT_PAGE_SIZE } from '@/config/setting';
import AiParamCreate from "./AiParamCreate";
const StyledDatagrid = styled(DatagridConfigurable)(({ theme }) => ({
    '& .css-1vooibu-MuiSvgIcon-root': {
        height: '.9em'
    },
    '& .RaDatagrid-row': {
        cursor: 'auto'
    },
    '& .opt': {
        width: 200
    },
}));
    TextInput,
    TopToolbar,
    useDelete,
    useListContext,
    useNotify,
    useRefresh,
} from "react-admin";
import {
    Box,
    Button,
    Card,
    CardActions,
    CardContent,
    Chip,
    CircularProgress,
    Divider,
    Grid,
    Stack,
    Typography,
} from "@mui/material";
import AddRoundedIcon from "@mui/icons-material/AddRounded";
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
import MyExportButton from "@/page/components/MyExportButton";
import AiParamForm from "./AiParamForm";
import AiConfigDialog from "../aiShared/AiConfigDialog";
const filters = [
    <SearchInput source="condition" alwaysOn />,
    <DateInput label='common.time.after' source="timeStart" alwaysOn />,
    <DateInput label='common.time.before' source="timeEnd" alwaysOn />,
    <TextInput source="name" label="table.field.aiParam.name" />,
    <TextInput source="modelCode" label="table.field.aiParam.modelCode" />,
    <TextInput source="provider" label="table.field.aiParam.provider" />,
    <TextInput source="modelName" label="table.field.aiParam.modelName" />,
    <TextInput source="providerType" label="提供方类型" />,
    <TextInput source="model" label="模型" />,
    <SelectInput
        source="defaultFlag"
        label="table.field.aiParam.defaultFlag"
        choices={[
            { id: '1', name: 'common.enums.true' },
            { id: '0', name: 'common.enums.false' },
        ]}
    />,
    <TextInput label="common.field.memo" source="memo" />,
    <SelectInput
        label="common.field.status"
        source="status"
        label="状态"
        choices={[
            { id: '1', name: 'common.enums.statusTrue' },
            { id: '0', name: 'common.enums.statusFalse' },
            { id: "1", name: "common.enums.statusTrue" },
            { id: "0", name: "common.enums.statusFalse" },
        ]}
    />,
]
];
const AiParamList = () => {
    const [createDialog, setCreateDialog] = useState(false);
const defaultValues = {
    providerType: "OPENAI_COMPATIBLE",
    temperature: 0.7,
    topP: 1,
    timeoutMs: 60000,
    streamingEnabled: true,
    status: 1,
};
const truncateText = (value, max = 84) => {
    if (!value) {
        return "--";
    }
    return value.length > max ? `${value.slice(0, max)}...` : value;
};
const AiParamCards = ({ onView, onEdit, onDelete, deleting }) => {
    const { data, isLoading } = useListContext();
    const records = useMemo(() => (Array.isArray(data) ? data : []), [data]);
    if (isLoading) {
        return (
            <Box display="flex" justifyContent="center" py={8}>
                <CircularProgress size={28} />
            </Box>
        );
    }
    if (!records.length) {
        return (
            <Box px={2} py={6}>
                <Card variant="outlined" sx={{ p: 3, textAlign: "center", borderStyle: "dashed" }}>
                    <Typography variant="subtitle1">暂无 AI 参数配置</Typography>
                    <Typography variant="body2" color="text.secondary" mt={1}>
                        可以先新建一个 OpenAI 兼容模型参数卡片。
                    </Typography>
                </Card>
            </Box>
        );
    }
    return (
        <Box display="flex">
        <Box px={2} py={2}>
            <Grid container spacing={2}>
                {records.map((record) => (
                    <Grid item xs={12} md={6} xl={4} key={record.id}>
                        <Card
                            variant="outlined"
                            sx={{
                                height: "100%",
                                borderRadius: 3,
                                boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)",
                            }}
                        >
                            <CardContent sx={{ pb: 1.5 }}>
                                <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
                                    <Box>
                                        <Typography variant="h6" sx={{ mb: 0.5 }}>
                                            {record.name}
                                        </Typography>
                                        <Typography variant="body2" color="text.secondary">
                                            {record.model || "--"}
                                        </Typography>
                                    </Box>
                                    <Chip
                                        size="small"
                                        color={record.statusBool ? "success" : "default"}
                                        label={record.statusBool ? "启用" : "停用"}
                                    />
                                </Stack>
                                <Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}>
                                    <Chip size="small" variant="outlined" label={record.providerType$ || "OPENAI_COMPATIBLE"} />
                                    <Chip
                                        size="small"
                                        variant="outlined"
                                        color={record.streamingEnabled ? "info" : "default"}
                                        label={record.streamingEnabled ? "流式响应" : "非流式"}
                                    />
                                </Stack>
                                <Divider sx={{ my: 1.5 }} />
                                <Typography variant="body2" color="text.secondary">
                                    Base URL
                                </Typography>
                                <Typography variant="body2" sx={{ mb: 1.5, wordBreak: "break-all" }}>
                                    {truncateText(record.baseUrl, 120)}
                                </Typography>
                                <Grid container spacing={1}>
                                    <Grid item xs={6}>
                                        <Typography variant="caption" color="text.secondary">Temperature</Typography>
                                        <Typography variant="body2">{record.temperature ?? "--"}</Typography>
                                    </Grid>
                                    <Grid item xs={6}>
                                        <Typography variant="caption" color="text.secondary">Top P</Typography>
                                        <Typography variant="body2">{record.topP ?? "--"}</Typography>
                                    </Grid>
                                    <Grid item xs={6}>
                                        <Typography variant="caption" color="text.secondary">Max Tokens</Typography>
                                        <Typography variant="body2">{record.maxTokens ?? "--"}</Typography>
                                    </Grid>
                                    <Grid item xs={6}>
                                        <Typography variant="caption" color="text.secondary">Timeout</Typography>
                                        <Typography variant="body2">{record.timeoutMs ?? "--"} ms</Typography>
                                    </Grid>
                                </Grid>
                                <Typography variant="caption" color="text.secondary" display="block" mt={1.5}>
                                    备注
                                </Typography>
                                <Typography variant="body2">{truncateText(record.memo)}</Typography>
                            </CardContent>
                            <CardActions sx={{ px: 2, pb: 2, pt: 0, justifyContent: "space-between" }}>
                                <Stack direction="row" spacing={1}>
                                    <Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record.id)}>
                                        详情
                                    </Button>
                                    <Button size="small" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(record.id)}>
                                        编辑
                                    </Button>
                                </Stack>
                                <Button
                                    size="small"
                                    color="error"
                                    startIcon={<DeleteOutlineOutlinedIcon />}
                                    onClick={() => onDelete(record)}
                                    disabled={deleting}
                                >
                                    删除
                                </Button>
                            </CardActions>
                        </Card>
                    </Grid>
                ))}
            </Grid>
        </Box>
    );
};
const AiParamList = () => {
    const notify = useNotify();
    const refresh = useRefresh();
    const [deleteOne, { isPending: deleting }] = useDelete();
    const [dialogState, setDialogState] = useState({ open: false, mode: "create", recordId: null });
    const openDialog = (mode, recordId = null) => setDialogState({ open: true, mode, recordId });
    const closeDialog = () => setDialogState({ open: false, mode: "create", recordId: null });
    const handleDelete = (record) => {
        if (!record?.id || !window.confirm(`确认删除“${record.name}”吗?`)) {
            return;
        }
        deleteOne(
            "aiParam",
            { id: record.id },
            {
                onSuccess: () => {
                    notify("删除成功");
                    refresh();
                },
                onError: (error) => {
                    notify(error?.message || "删除失败", { type: "error" });
                },
            }
        );
    };
    const dialogTitle = {
        create: "新建 AI 参数",
        edit: "编辑 AI 参数",
        show: "查看 AI 参数详情",
    }[dialogState.mode];
    return (
        <>
            <List
                title={"menu.aiParam"}
                empty={<EmptyData onClick={() => { setCreateDialog(true) }} />}
                title="menu.aiParam"
                filters={filters}
                sort={{ field: "sort", order: "asc" }}
                sort={{ field: "create_time", order: "desc" }}
                actions={(
                    <TopToolbar>
                        <FilterButton />
                        <MyCreateButton onClick={() => { setCreateDialog(true) }} />
                        <SelectColumnsButton preferenceKey='aiParam' />
                        <Button variant="contained" startIcon={<AddRoundedIcon />} onClick={() => openDialog("create")}>
                            新建
                        </Button>
                        <MyExportButton />
                    </TopToolbar>
                )}
                perPage={DEFAULT_PAGE_SIZE}
            >
                <StyledDatagrid
                    preferenceKey='aiParam'
                    bulkActionButtons={() => <BulkDeleteButton mutationMode={OPERATE_MODE} />}
                    rowClick={false}
                    omit={['id', 'createTime', 'memo', 'statusBool', 'defaultFlagBool']}
                >
                    <NumberField source="id" />
                    <TextField source="uuid" label="table.field.aiParam.uuid" />
                    <TextField source="name" label="table.field.aiParam.name" />
                    <TextField source="modelCode" label="table.field.aiParam.modelCode" />
                    <TextField source="provider" label="table.field.aiParam.provider" />
                    <TextField source="modelName" label="table.field.aiParam.modelName" />
                    <NumberField source="maxContextMessages" label="table.field.aiParam.maxContextMessages" />
                    <NumberField source="sort" label="table.field.aiParam.sort" />
                    <BooleanField source="defaultFlagBool" label="table.field.aiParam.defaultFlag" sortable={false} />
                    <BooleanField source="statusBool" label="common.field.status" sortable={false} />
                    <DateField source="updateTime" label="common.field.updateTime" showTime />
                    <TextField source="memo" label="common.field.memo" sortable={false} />
                    <WrapperField cellClassName="opt" label="common.field.opt">
                        <EditButton sx={{ padding: '1px', fontSize: '.75rem' }} />
                        <DeleteButton sx={{ padding: '1px', fontSize: '.75rem' }} mutationMode={OPERATE_MODE} />
                    </WrapperField>
                </StyledDatagrid>
                <AiParamCards
                    onView={(id) => openDialog("show", id)}
                    onEdit={(id) => openDialog("edit", id)}
                    onDelete={handleDelete}
                    deleting={deleting}
                />
            </List>
            <AiParamCreate
                open={createDialog}
                setOpen={setCreateDialog}
            />
        </Box>
    )
}
            <AiConfigDialog
                open={dialogState.open}
                mode={dialogState.mode}
                title={dialogTitle}
                resource="aiParam"
                recordId={dialogState.recordId}
                defaultValues={defaultValues}
                maxWidth="md"
                onClose={closeDialog}
            >
                <AiParamForm readOnly={dialogState.mode === "show"} />
            </AiConfigDialog>
        </>
    );
};
export default AiParamList;