zhang
9 天以前 70930071a49190f414c8d8bc9c9e9795a4096739
zy-acs-flow/src/map/insight/code/CodeMain.jsx
New file
@@ -0,0 +1,691 @@
import React, { useMemo } from 'react';
import { useTranslate, useRedirect } from 'react-admin';
import {
    Box,
    Paper,
    Typography,
    Stack,
    Grid,
    Chip,
    Button,
    Divider,
    Skeleton,
    useTheme,
} from '@mui/material';
import { alpha } from '@mui/material/styles';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import BoolValueIcon from '../BoolValueIcon';
import { rotationToNum } from '../../tool';
import CodeQr from './CodeQr';
const DIR_RULE_ANGLES = [0, 90, 180, 270];
const MAX_RELATION_ITEMS = 8;
const CodeMain = ({ sprite, codeInfo, loading, code }) => {
    const translate = useTranslate();
    const redirect = useRedirect();
    const theme = useTheme();
    const info = codeInfo || {};
    const displayCode = info?.data ?? info?.code ?? sprite?.data?.no ?? code;
    const zoneName = info?.zoneName ?? info?.zoneId$ ?? sprite?.data?.zoneName;
    const statusBool = detectBoolean(info?.statusBool ?? info?.status);
    const statusLabel = info?.status$ || translate(statusBool ? 'common.enums.statusTrue' : 'common.enums.statusFalse');
    const cornerBool = detectBoolean(info?.cornerBool ?? info?.corner ?? sprite?.data?.corner);
    const spinText = getSpinText(info?.spin, translate);
    const mapMetrics = useMemo(() => {
        if (!sprite) {
            return null;
        }
        const posX = sprite.position?.x;
        const posY = sprite.position?.y;
        const rotation = rotationToNum(sprite.rotation || 0);
        const scaleX = sprite.scale?.x;
        const scaleY = sprite.scale?.y;
        return {
            posX: isFiniteNumber(posX) ? posX : null,
            posY: isFiniteNumber(posY) ? posY : null,
            rotation: isFiniteNumber(rotation) ? rotation : null,
            scaleX: isFiniteNumber(scaleX) ? scaleX : null,
            scaleY: isFiniteNumber(scaleY) ? scaleY : null,
        };
    }, [sprite]);
    const routeRelations = useMemo(
        () => extractRelationItems(info, ['routeList', 'routes', 'routeRefs']),
        [info]
    );
    const funcStaRelations = useMemo(
        () => extractRelationItems(info, ['funcStaList', 'funcStas']),
        [info]
    );
    const ruleList = useMemo(() => normalizeDirRule(info?.dirRule), [info?.dirRule]);
    const spatialItems = [
        {
            label: translate('page.map.insight.code.fields.mapPosition', { _: '地图坐标' }),
            render: () => (
                <CoordinatePair
                    x={formatNumber(mapMetrics?.posX, 0)}
                    y={formatNumber(mapMetrics?.posY, 0)}
                />
            ),
            always: mapMetrics?.posX != null || mapMetrics?.posY != null,
            hideWhenEmpty: true,
        },
        {
            label: translate('page.map.insight.code.fields.rotation', { _: '旋转角度(°)' }),
            value: mapMetrics?.rotation != null ? `${formatNumber(mapMetrics.rotation, 1)}°` : null,
            hideWhenEmpty: true
        },
        {
            label: translate('table.field.code.corner', { _: '拐角' }),
            render: () => (
                <BooleanDisplay
                    value={cornerBool}
                    label={
                        cornerBool == null
                            ? translate('common.enums.na')
                            : translate(cornerBool ? 'common.enums.true' : 'common.enums.false')
                    }
                />
            ),
            always: true,
        },
    ];
    const handleOpenDetail = () => {
        if (info?.id) {
            redirect('edit', 'code', info.id);
        }
    };
    return (
        <Box sx={{ pr: 1, pb: 3 }}>
            <Grid container spacing={3} alignItems="flex-start">
                <Grid item xs={12} md={5}>
                    <Stack spacing={3}>
                        <QrPreview
                            translate={translate}
                            value={displayCode}
                            loading={loading}
                            statusLabel={statusLabel}
                            statusBool={statusBool}
                        />
                        <Paper
                            sx={{
                                borderRadius: 4,
                                border: '1px solid',
                                borderColor: 'divider',
                                backgroundColor: theme.palette.background.paper,
                                p: { xs: 2.5, md: 3 },
                            }}
                        >
                            <Stack spacing={2}>
                                {zoneName && (
                                    <Chip
                                        label={zoneName}
                                        variant="outlined"
                                        size="small"
                                        color="primary"
                                        sx={{ alignSelf: 'flex-start' }}
                                    />
                                )}
                                <FieldGrid items={spatialItems} loading={loading} />
                            </Stack>
                        </Paper>
                        <Button
                            variant="contained"
                            color="primary"
                            startIcon={<OpenInNewIcon />}
                            onClick={handleOpenDetail}
                            disabled={!info?.id}
                            sx={{ alignSelf: 'flex-start', textTransform: 'none', px: 3 }}
                        >
                            {translate('page.map.insight.code.actions.openDetail', { _: '编辑' })}
                        </Button>
                    </Stack>
                </Grid>
                <Grid item xs={12} md={7}>
                    <Paper
                        sx={{
                            borderRadius: 4,
                            border: '1px solid',
                            borderColor: 'divider',
                            backgroundColor: theme.palette.background.paper,
                            p: { xs: 2.5, md: 3 },
                        }}
                    >
                        <Stack spacing={3}>
                            <InfoPanel title={translate('page.map.insight.code.sections.rules', { _: '通行规则' })}>
                                <RulesSection
                                    loading={loading}
                                    cornerBool={cornerBool}
                                    spinText={spinText}
                                    rules={ruleList}
                                    translate={translate}
                                />
                            </InfoPanel>
                            <InfoPanel title={translate('page.map.insight.code.relations.routes', { _: '关联路线' })}>
                                <RelationsList
                                    items={routeRelations}
                                    emptyLabel={translate('page.map.insight.code.relations.empty', { _: '暂无关联信息' })}
                                />
                            </InfoPanel>
                            <InfoPanel title={translate('menu.funcSta', { _: '功能站' })}>
                                <RelationsChips
                                    items={funcStaRelations}
                                    emptyLabel={translate('page.map.insight.code.relations.empty', { _: '暂无关联信息' })}
                                />
                            </InfoPanel>
                        </Stack>
                    </Paper>
                </Grid>
            </Grid>
        </Box>
    );
};
const InfoPanel = ({ title, children }) => (
    <Box
        sx={{
            border: '1px solid',
            borderColor: 'divider',
            borderRadius: 3,
            p: { xs: 2, sm: 2.5 },
            backgroundColor: 'background.paper',
        }}
    >
        <Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
            {title}
        </Typography>
        {children}
    </Box>
);
const RelationsChips = ({ items, emptyLabel }) => {
    if (!items?.length) {
        return (
            <Typography variant="body2" color="text.disabled">
                {emptyLabel}
            </Typography>
        );
    }
    return (
        <Stack direction="row" spacing={1} flexWrap="wrap">
            {items.slice(0, MAX_RELATION_ITEMS).map((item, index) => (
                <Chip
                    key={`${getRelationKey(item)}-${index}`}
                    label={getRelationLabel(item)}
                    variant="outlined"
                    size="small"
                />
            ))}
            {items.length > MAX_RELATION_ITEMS && (
                <Chip
                    label={`+${items.length - MAX_RELATION_ITEMS}`}
                    size="small"
                />
            )}
        </Stack>
    );
};
const RelationsList = ({ items, emptyLabel }) => {
    if (!items?.length) {
        return (
            <Typography variant="body2" color="text.disabled">
                {emptyLabel}
            </Typography>
        );
    }
    return (
        <Stack spacing={1}>
            {items.slice(0, MAX_RELATION_ITEMS).map((item, index) => (
                <Box
                    key={`${getRelationKey(item)}-${index}`}
                    sx={{
                        px: 1.25,
                        py: 0.75,
                        borderRadius: 999,
                        border: '1px solid',
                        borderColor: 'divider',
                        backgroundColor: 'background.default',
                    }}
                >
                    <Typography variant="body2" sx={{ lineHeight: 1.2 }}>
                        {getRelationLabel(item)}
                    </Typography>
                </Box>
            ))}
            {items.length > MAX_RELATION_ITEMS && (
                <Typography variant="caption" color="text.secondary">
                    +{items.length - MAX_RELATION_ITEMS}
                </Typography>
            )}
        </Stack>
    );
};
const QrPreview = ({ translate, value, loading, statusLabel, statusBool }) => (
    <Paper
        elevation={3}
        sx={{
            borderRadius: 4,
            px: 3,
            py: 3,
            width: '100%',
            maxWidth: 320,
            alignSelf: { xs: 'center', lg: 'flex-start' },
            minHeight: 320,
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            justifyContent: 'center',
            gap: 1,
        }}
    >
        <Typography variant="caption" color="text.secondary" sx={{ letterSpacing: 1, textTransform: 'uppercase' }}>
            {translate('page.map.insight.code.summary.qr', { _: '二维码预览' })}
        </Typography>
        <CodeQr value={value} loading={loading} />
        <Typography variant="caption" color="text.secondary">
            QR CODE
        </Typography>
        {!loading && (
            <Stack spacing={0.5} alignItems="center" sx={{ mt: 0.5 }}>
                <Typography variant="caption" color="text.secondary">
                    {translate('common.field.status', { _: '状态' })}
                </Typography>
                <Chip
                    label={statusLabel}
                    color={statusBool ? 'success' : 'default'}
                    variant="outlined"
                    size="small"
                    sx={{ fontWeight: 500 }}
                />
            </Stack>
        )}
    </Paper>
);
const FieldGrid = ({ items, loading }) => {
    const candidates = (loading ? items : items.filter(item => {
        if (!item) {
            return false;
        }
        if (item.always) {
            return true;
        }
        if (item.hideWhenEmpty) {
            return hasDisplayValue(item.value);
        }
        return true;
    })).filter(Boolean);
    if (!candidates.length) {
        return loading ? <Skeleton width="40%" /> : (
            <Typography variant="body2" color="text.disabled">
                -
            </Typography>
        );
    }
    return (
        <Box
            sx={{
                display: 'grid',
                gap: 2,
                gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
            }}
        >
            {candidates.map((item, index) => (
                <Stack key={`${item.label}-${index}`} spacing={0.5}>
                    <Typography variant="caption" color="text.secondary">
                        {item.label}
                    </Typography>
                    <Box>
                        {item.render
                            ? item.render({ loading })
                            : loading
                                ? <Skeleton width="60%" />
                                : renderDisplayValue(item.value)}
                    </Box>
                </Stack>
            ))}
        </Box>
    );
};
const renderDisplayValue = (value) => {
    if (React.isValidElement(value)) {
        return value;
    }
    if (value === null || value === undefined || value === '') {
        return (
            <Typography variant="body2" color="text.disabled">
                -
            </Typography>
        );
    }
    return (
        <Typography variant="subtitle1" sx={{ wordBreak: 'break-word', fontWeight: 600 }}>
            {value}
        </Typography>
    );
};
const hasDisplayValue = (value) => {
    if (value === 0 || value === false) {
        return true;
    }
    if (typeof value === 'number') {
        return !Number.isNaN(value);
    }
    if (value instanceof Date) {
        return true;
    }
    return value !== null && value !== undefined && String(value).trim() !== '';
};
const BooleanDisplay = ({ value, label }) => {
    if (value == null) {
        return (
            <Typography variant="body2" color="text.disabled">
                {label}
            </Typography>
        );
    }
    return (
        <Stack direction="row" alignItems="center" spacing={1}>
            <BoolValueIcon value={value} />
            <Typography variant="body2">{label}</Typography>
        </Stack>
    );
};
const CoordinatePair = ({ x, y }) => {
    if (!hasDisplayValue(x) && !hasDisplayValue(y)) {
        return renderDisplayValue(null);
    }
    return (
        <Stack direction="row" spacing={2.5} alignItems="center" flexWrap="wrap">
            <Stack spacing={0.25}>
                <Typography variant="caption" color="text.secondary">
                    X
                </Typography>
                <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
                    {hasDisplayValue(x) ? x : '-'}
                </Typography>
            </Stack>
            <Stack spacing={0.25}>
                <Typography variant="caption" color="text.secondary">
                    Y
                </Typography>
                <Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
                    {hasDisplayValue(y) ? y : '-'}
                </Typography>
            </Stack>
        </Stack>
    );
};
const RulesSection = ({ cornerBool, spinText, rules, translate, loading }) => (
    <Stack spacing={2}>
        <Box
            sx={{
                display: 'grid',
                gridTemplateColumns: { xs: '1fr', sm: 'repeat(2, minmax(0, 1fr))' },
                gap: 2,
            }}
        >
            <RuleMetric
                label={translate('table.field.code.corner')}
                value={
                    <BooleanDisplay
                        value={cornerBool}
                        label={cornerBool == null
                            ? translate('common.enums.na')
                            : translate(cornerBool ? 'common.enums.true' : 'common.enums.false')}
                    />
                }
            />
            <RuleMetric
                label={translate('table.field.code.spin')}
                value={
                    <Chip
                        label={spinText}
                        size="small"
                        color="primary"
                        variant="outlined"
                    />
                }
            />
        </Box>
        <Divider />
        {loading ? (
            <Skeleton variant="rounded" height={188} />
        ) : (
            <DirectionRuleCompass rules={rules} translate={translate} />
        )}
    </Stack>
);
const DirectionRuleCompass = ({ rules, translate }) => {
    const theme = useTheme();
    const enabledCount = rules.filter(rule => rule.enabled).length;
    const topRule = rules.find(rule => rule.angle === 0);
    const rightRule = rules.find(rule => rule.angle === 90);
    const bottomRule = rules.find(rule => rule.angle === 180);
    const leftRule = rules.find(rule => rule.angle === 270);
    return (
        <Stack spacing={0.85} alignItems="center">
            <DirectionRuleCard rule={topRule} translate={translate} />
            <Stack direction="row" spacing={0.6} alignItems="center" justifyContent="center">
                <DirectionRuleCard rule={leftRule} translate={translate} />
                <Box
                    sx={{
                        width: 52,
                        height: 42,
                        borderRadius: 2.5,
                        border: '1px dashed',
                        borderColor: 'divider',
                        backgroundColor: alpha(theme.palette.primary.main, 0.04),
                        display: 'flex',
                        alignItems: 'center',
                        justifyContent: 'center',
                        textAlign: 'center',
                        flexShrink: 0,
                    }}
                >
                    <Typography variant="h5" sx={{ lineHeight: 1, fontWeight: 700, fontSize: '0.98rem' }}>
                        {enabledCount}/{rules.length}
                    </Typography>
                </Box>
                <DirectionRuleCard rule={rightRule} translate={translate} />
            </Stack>
            <DirectionRuleCard rule={bottomRule} translate={translate} />
        </Stack>
    );
};
const DirectionRuleCard = ({ rule, translate }) => {
    const theme = useTheme();
    if (!rule) {
        return null;
    }
    const statusText = translate(
        rule.enabled ? 'page.code.dirRule.status.enabled' : 'page.code.dirRule.status.disabled'
    );
    return (
        <Box
            sx={{
                width: 68,
                height: 42,
                borderRadius: 2.5,
                border: '1px solid',
                borderColor: rule.enabled ? 'success.light' : 'error.light',
                backgroundColor: rule.enabled
                    ? alpha(theme.palette.success.main, 0.08)
                    : alpha(theme.palette.error.main, 0.08),
                px: 0.5,
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center',
                textAlign: 'center',
                flexShrink: 0,
            }}
        >
            <Stack spacing={0.1}>
                <Typography variant="subtitle2" sx={{ fontWeight: 700, lineHeight: 1, fontSize: '0.88rem' }}>
                    {rule.angle}°
                </Typography>
                <Typography
                    variant="caption"
                    sx={{
                        lineHeight: 1.05,
                        fontSize: '0.64rem',
                        color: rule.enabled ? 'success.dark' : 'error.dark',
                        fontWeight: 600,
                    }}
                >
                    {statusText}
                </Typography>
            </Stack>
        </Box>
    );
};
const RuleMetric = ({ label, value }) => (
    <Stack spacing={0.5}>
        <Typography variant="caption" color="text.secondary">
            {label}
        </Typography>
        {value}
    </Stack>
);
const normalizeDirRule = (value) => {
    if (!value) {
        return DIR_RULE_ANGLES.map(angle => ({ angle, enabled: true }));
    }
    let parsed = value;
    if (typeof parsed === 'string') {
        try {
            parsed = JSON.parse(parsed);
        } catch (error) {
            parsed = [];
        }
    }
    if (!Array.isArray(parsed)) {
        parsed = [parsed];
    }
    const normalized = DIR_RULE_ANGLES.map(angle => ({ angle, enabled: true }));
    parsed.forEach(rule => {
        const angle = typeof rule?.angle === 'number' ? rule.angle : Number(rule?.angle);
        if (!Number.isFinite(angle)) {
            return;
        }
        const normalizedAngle = ((angle % 360) + 360) % 360;
        const target = normalized.find(item => item.angle === normalizedAngle);
        if (target) {
            const disabled = rule?.enabled === false || rule?.enabled === 'false' || rule?.value === false || rule?.value === 'false';
            target.enabled = !disabled;
        }
    });
    return normalized;
};
const getRelationKey = (item) => {
    if (item && typeof item === 'object') {
        return item.id || item.uuid || item.code || item.data || item.no || JSON.stringify(item);
    }
    return item ?? 'relation';
};
const getRelationLabel = (item) => {
    if (item == null) {
        return '-';
    }
    if (typeof item === 'string' || typeof item === 'number') {
        return item;
    }
    if (typeof item === 'object') {
        if (item.startCode$ && item.endCode$) {
            return `${item.startCode$} -> ${item.endCode$}`;
        }
        if (item.name || item.type || item.state) {
            return [item.name, item.type, item.state].filter(Boolean).join(' / ');
        }
        return item.name || item.code || item.data || item.no || item.uuid || item.locNo || item.staNo || JSON.stringify(item);
    }
    return String(item);
};
const extractRelationItems = (info, keys) => {
    if (!info) {
        return [];
    }
    const dataset = keys
        .map(key => info?.[key])
        .find(value => Array.isArray(value) && value.length);
    return Array.isArray(dataset) ? dataset : [];
};
const detectBoolean = (value) => {
    if (typeof value === 'boolean') {
        return value;
    }
    if (value === 1 || value === '1') {
        return true;
    }
    if (value === 0 || value === '0') {
        return false;
    }
    if (value === 'true') {
        return true;
    }
    if (value === 'false') {
        return false;
    }
    return null;
};
const getSpinText = (spin, translate) => {
    switch (spin) {
        case 1:
            return translate('page.code.enums.spin.cw');
        case 2:
            return translate('page.code.enums.spin.ccw');
        case 0:
            return translate('page.code.enums.spin.na');
        default:
            return translate('page.code.enums.spin.na');
    }
};
const isFiniteNumber = (value) => Number.isFinite(Number(value));
const formatNumber = (value, precision = 2) => {
    if (value === null || value === undefined || value === '') {
        return null;
    }
    const num = Number(value);
    if (!Number.isFinite(num)) {
        return value;
    }
    if (precision === null) {
        return num;
    }
    return num.toFixed(precision);
};
export default CodeMain;