feat(web): 蚕房健康画像与年度发病规律图表(#24)

This commit is contained in:
weijuesen
2026-08-13 13:35:23 +08:00
parent cbcb22e851
commit ccda60e50b
3 changed files with 94 additions and 1 deletions
+21
View File
@@ -0,0 +1,21 @@
import { get } from '../api/http';
export interface HealthProfile {
roomId: string;
roomName: string;
score: number;
grade: string;
riskDistribution: { green: number; yellow: number; orange: number; red: number };
lampResults: { total: number; positive: number };
events: { consultations: number; traces: number };
batch?: any;
}
export const getHealthProfile = (roomId: string) =>
get<HealthProfile>(`/rooms/${roomId}/health-profile`);
export const getMonthlyStats = (year?: number) =>
get<{ year: number; stats: { month: string; total: number; diseases: Record<string, number> }[] }>(
'/trace-records/monthly-stats',
{ params: { year } },
);
+55 -1
View File
@@ -1,13 +1,17 @@
import { useState } from 'react'; import { useState } from 'react';
import { Button, Form, Input, Modal, Popconfirm, Select, Tag, message } from 'antd'; import { Button, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Tag, message, Progress } from 'antd';
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { ProTable, type ProColumns } from '@ant-design/pro-components'; import { ProTable, type ProColumns } from '@ant-design/pro-components';
import { listHouses, createHouse, updateHouse, deleteHouse, type SilkwormHouse } from '../dal/silkworm'; import { listHouses, createHouse, updateHouse, deleteHouse, type SilkwormHouse } from '../dal/silkworm';
import { getHealthProfile, type HealthProfile } from '../dal/health';
export default function SilkwormPage() { export default function SilkwormPage() {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [form] = Form.useForm<Partial<SilkwormHouse>>(); const [form] = Form.useForm<Partial<SilkwormHouse>>();
const [editing, setEditing] = useState<SilkwormHouse | null>(null); const [editing, setEditing] = useState<SilkwormHouse | null>(null);
const [profileOpen, setProfileOpen] = useState(false);
const [profile, setProfile] = useState<HealthProfile | null>(null);
const [profileLoading, setProfileLoading] = useState(false);
const columns: ProColumns<SilkwormHouse>[] = [ const columns: ProColumns<SilkwormHouse>[] = [
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 }, { title: '序号', valueType: 'indexBorder', search: false, width: 60 },
@@ -44,6 +48,20 @@ export default function SilkwormPage() {
title: '操作', title: '操作',
valueType: 'option', valueType: 'option',
render: (_, r) => [ render: (_, r) => [
<a
key="health"
onClick={async () => {
setProfileOpen(true);
setProfileLoading(true);
try {
setProfile(await getHealthProfile(r.id));
} finally {
setProfileLoading(false);
}
}}
>
</a>,
<a <a
key="edit" key="edit"
onClick={() => { onClick={() => {
@@ -131,6 +149,42 @@ export default function SilkwormPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Drawer title={profile ? `${profile.roomName} - 健康画像` : '健康画像'} open={profileOpen} width={520} onClose={() => setProfileOpen(false)}>
{profileLoading ? <div>...</div> : null}
{profile ? (
<div>
<Progress
type="dashboard"
percent={Math.round(profile.score)}
format={() => `${Math.round(profile.score)}`}
strokeColor={profile.score >= 85 ? '#52c41a' : profile.score >= 70 ? '#10b981' : profile.score >= 55 ? '#faad14' : '#f5222d'}
/>
<Descriptions column={1} style={{ marginTop: 16 }}>
<Descriptions.Item label="综合评级">
<Tag color={profile.grade === '优' ? 'green' : profile.grade === '良' ? 'blue' : profile.grade === '中' ? 'gold' : 'red'}>
{profile.grade}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="巡检风险分布">
绿 {profile.riskDistribution.green} / {profile.riskDistribution.yellow} / {' '}
{profile.riskDistribution.orange} / {profile.riskDistribution.red}
</Descriptions.Item>
<Descriptions.Item label="检测结果(近30天)">
{profile.lampResults.total} {profile.lampResults.positive}
</Descriptions.Item>
<Descriptions.Item label="事件">
{profile.events.consultations} / {profile.events.traces}
</Descriptions.Item>
{profile.batch ? (
<Descriptions.Item label="当前批次">
{profile.batch.name}
{profile.batch.instar ? `${profile.batch.instar}龄)` : ''}
</Descriptions.Item>
) : null}
</Descriptions>
</div>
) : null}
</Drawer>
</> </>
); );
} }
+18
View File
@@ -19,6 +19,7 @@ import {
type ChecklistItem, type ChecklistItem,
type RegionStat, type RegionStat,
} from '../dal/trace'; } from '../dal/trace';
import { getMonthlyStats } from '../dal/health';
import { authService } from '../services/auth'; import { authService } from '../services/auth';
const STATUS_LABELS: Record<string, { color: string; text: string }> = { const STATUS_LABELS: Record<string, { color: string; text: string }> = {
@@ -32,6 +33,7 @@ const canWrite = () => authService.hasPermission('trace:write');
export default function TracesPage() { export default function TracesPage() {
const [regionStats, setRegionStats] = useState<RegionStat[]>([]); const [regionStats, setRegionStats] = useState<RegionStat[]>([]);
const [monthlyStats, setMonthlyStats] = useState<{ month: string; total: number }[]>([]);
const actionRef = useRef<ActionType>(); const actionRef = useRef<ActionType>();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm<Partial<TraceRecord>>(); const [createForm] = Form.useForm<Partial<TraceRecord>>();
@@ -43,6 +45,9 @@ export default function TracesPage() {
getRegionStats(90) getRegionStats(90)
.then(setRegionStats) .then(setRegionStats)
.catch(() => {}); .catch(() => {});
getMonthlyStats()
.then((res) => setMonthlyStats(res.stats.map((s) => ({ month: s.month, total: s.total }))))
.catch(() => {});
}, []); }, []);
const barOption = { const barOption = {
@@ -71,6 +76,14 @@ export default function TracesPage() {
], ],
}; };
const monthlyOption = {
title: { text: '年度发病规律' },
tooltip: {},
xAxis: { type: 'category', data: monthlyStats.map((s) => s.month) },
yAxis: { type: 'value', minInterval: 1 },
series: [{ type: 'line', data: monthlyStats.map((s) => s.total), smooth: true, areaStyle: {} }],
};
const openDetail = async (r: TraceRecord) => { const openDetail = async (r: TraceRecord) => {
setDetail(r); setDetail(r);
if (r.status !== 'archived') { if (r.status !== 'archived') {
@@ -162,6 +175,11 @@ export default function TracesPage() {
</div> </div>
</div> </div>
) : null} ) : null}
{monthlyStats.length > 0 ? (
<div style={{ background: '#fff', borderRadius: 8, padding: 8, marginBottom: 16 }}>
<ReactECharts option={monthlyOption} style={{ height: 240 }} />
</div>
) : null}
<ProTable<TraceRecord> <ProTable<TraceRecord>
actionRef={actionRef} actionRef={actionRef}
rowKey="id" rowKey="id"