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;
|