<script lang="ts" setup>
|
import type { CSSProperties } from 'vue';
|
|
import type { HuanjingKanbanItem, HuanjingKanbanRow } from '#/api/x/lims/shiyanshi';
|
|
import { computed, onMounted, reactive, ref } from 'vue';
|
|
import { useMessage } from '@jnpf/hooks';
|
|
import { ReloadOutlined } from '@ant-design/icons-vue';
|
import { Spin as ASpin } from 'ant-design-vue';
|
import dayjs from 'dayjs';
|
|
import { deleteHuanjingKanbanItem, getHuanjingKanbanItems, getHuanjingKanbanRows } from '#/api/x/lims/shiyanshi';
|
import { useBaseStore } from '#/store';
|
import { onlineUtils } from '#/utils/jnpf';
|
|
defineOptions({ name: 'XlimsShiyanshiHuanjingKanban' });
|
|
interface DicOption {
|
enCode?: string;
|
fullName: string;
|
id: string;
|
}
|
|
interface SelectOption {
|
label: string;
|
value: string;
|
}
|
|
interface DetailPanel {
|
date: string;
|
items: HuanjingKanbanItem[];
|
row: HuanjingKanbanRow | null;
|
style: CSSProperties & Record<string, string>;
|
visible: boolean;
|
}
|
|
const baseStore = useBaseStore();
|
const { createMessage } = useMessage();
|
|
const loading = ref(false);
|
const monthValue = ref(dayjs().format('YYYY-MM'));
|
const allRows = ref<HuanjingKanbanRow[]>([]);
|
const rows = ref<HuanjingKanbanRow[]>([]);
|
const items = ref<HuanjingKanbanItem[]>([]);
|
const levelOptionList = ref<SelectOption[]>([]);
|
const boardRef = ref<HTMLElement>();
|
const searchForm = reactive({
|
jiance_quyu: undefined as string | undefined,
|
jiejing_jibie: undefined as string | undefined,
|
weizhi_bianhao: undefined as string | undefined,
|
});
|
const dictMap = reactive<Record<string, Record<string, string>>>({
|
huanjingJianceJiejingJibie: {},
|
huanjingJianceJihuaLeixing: {},
|
huanjingJianceJiluZhuangtai: {},
|
huanjingJianceXiangmu: {},
|
huanjingJianceZhuangtai: {},
|
});
|
const detailPanel = reactive<DetailPanel>({
|
date: '',
|
items: [],
|
row: null,
|
style: {},
|
visible: false,
|
});
|
|
const currentMonth = computed(() => dayjs(monthValue.value).startOf('month'));
|
const monthStart = computed(() => currentMonth.value.startOf('month'));
|
const monthEnd = computed(() => currentMonth.value.endOf('month'));
|
const days = computed(() => Array.from({ length: currentMonth.value.daysInMonth() }, (_, index) => index + 1));
|
const todayText = dayjs().format('YYYY-MM-DD');
|
const doneStatusSet = new Set(['audited', 'pending_audit', 'received']);
|
|
const areaOptions = computed<SelectOption[]>(() => {
|
const map = new Map<string, string>();
|
allRows.value.forEach((row) => {
|
if (row.jiance_quyu) map.set(row.jiance_quyu, row.jiance_quyu_mingcheng || row.jiance_quyu);
|
});
|
return [...map.entries()].map(([value, label]) => ({ label, value }));
|
});
|
const roomOptions = computed<SelectOption[]>(() => {
|
const map = new Map<string, string>();
|
allRows.value
|
.filter((row) => !searchForm.jiance_quyu || row.jiance_quyu === searchForm.jiance_quyu)
|
.filter((row) => !searchForm.jiejing_jibie || row.jiejing_jibie === searchForm.jiejing_jibie)
|
.forEach((row) => {
|
if (row.weizhi_bianhao) map.set(row.weizhi_bianhao, row.weizhi_mingcheng || row.weizhi_bianhao);
|
});
|
return [...map.entries()].map(([value, label]) => ({ label, value }));
|
});
|
const levelOptions = computed<SelectOption[]>(() => levelOptionList.value);
|
|
function validateRoomSelection() {
|
if (!searchForm.weizhi_bianhao) return;
|
const matched = allRows.value.some(
|
(row) =>
|
row.weizhi_bianhao === searchForm.weizhi_bianhao &&
|
(!searchForm.jiance_quyu || row.jiance_quyu === searchForm.jiance_quyu) &&
|
(!searchForm.jiejing_jibie || row.jiejing_jibie === searchForm.jiejing_jibie),
|
);
|
if (!matched) searchForm.weizhi_bianhao = undefined;
|
}
|
const itemMap = computed(() => {
|
const map = new Map<string, HuanjingKanbanItem[]>();
|
items.value.forEach((item) => {
|
const date = normalizeDate(item.jihua_riqi);
|
const key = `${getRowKey(item)}|${date}`;
|
const list = map.get(key) || [];
|
const itemKey = getItemDedupKey(item);
|
if (!list.some((record) => getItemDedupKey(record) === itemKey)) {
|
list.push(item);
|
}
|
map.set(key, list);
|
});
|
return map;
|
});
|
|
function normalizeDate(value?: string) {
|
if (!value) return '';
|
return dayjs(value).format('YYYY-MM-DD');
|
}
|
function getRowKey(record: Pick<HuanjingKanbanItem | HuanjingKanbanRow, 'jiance_quyu' | 'jiejing_jibie' | 'weizhi_bianhao'>) {
|
return `${record.jiance_quyu || ''}__${record.weizhi_bianhao || ''}__${record.jiejing_jibie || ''}`;
|
}
|
function getItemDedupKey(record: HuanjingKanbanItem) {
|
return `${record.jilu_id || ''}__${getRowKey(record)}`;
|
}
|
function getCellDate(day: number) {
|
return currentMonth.value.date(day).format('YYYY-MM-DD');
|
}
|
function getCellItems(row: HuanjingKanbanRow, day: number) {
|
return itemMap.value.get(`${getRowKey(row)}|${getCellDate(day)}`) || [];
|
}
|
function getCellClass(day: number) {
|
return getCellDate(day) === todayText ? 'is-today' : '';
|
}
|
function isDoneCell(list: HuanjingKanbanItem[]) {
|
return list.length > 0 && list.every((item) => doneStatusSet.has(item.biz_status));
|
}
|
function getMarkClass(list: HuanjingKanbanItem[]) {
|
return isDoneCell(list) ? 'is-done' : 'is-pending';
|
}
|
function getMarkText(list: HuanjingKanbanItem[]) {
|
return isDoneCell(list) ? '✓' : '!';
|
}
|
function getDictLabel(type: string, value?: string) {
|
if (!value) return '';
|
return dictMap[type]?.[value] || value;
|
}
|
function cleanQuery(extra: Record<string, any> = {}) {
|
const query: Record<string, any> = {
|
jiance_quyu: searchForm.jiance_quyu,
|
jiejing_jibie: searchForm.jiejing_jibie,
|
weizhi_bianhao: searchForm.weizhi_bianhao,
|
...extra,
|
};
|
Object.keys(query).forEach((key) => {
|
if (query[key] === undefined || query[key] === '') delete query[key];
|
});
|
return query;
|
}
|
|
async function loadDictionaries() {
|
const codes = Object.keys(dictMap);
|
await Promise.all(
|
codes.map(async (code) => {
|
const list = ((await baseStore.getDictionaryData(code)) || []) as DicOption[];
|
dictMap[code] = list.reduce<Record<string, string>>((prev, item) => {
|
if (item.id) prev[item.id] = item.fullName;
|
if (item.enCode) prev[item.enCode] = item.fullName;
|
return prev;
|
}, {});
|
if (code === 'huanjingJianceJiejingJibie') {
|
levelOptionList.value = list.map((item) => ({
|
label: item.fullName,
|
value: item.enCode || item.id,
|
}));
|
}
|
}),
|
);
|
}
|
async function loadAllRows() {
|
const res = await getHuanjingKanbanRows({});
|
allRows.value = res?.data || [];
|
}
|
async function reload() {
|
loading.value = true;
|
closeDetail();
|
try {
|
const query = cleanQuery({
|
end_date: monthEnd.value.format('YYYY-MM-DD'),
|
start_date: monthStart.value.format('YYYY-MM-DD'),
|
});
|
const [rowRes, itemRes] = await Promise.all([getHuanjingKanbanRows(cleanQuery()), getHuanjingKanbanItems(query)]);
|
rows.value = rowRes?.data || [];
|
items.value = itemRes?.data || [];
|
} finally {
|
loading.value = false;
|
}
|
}
|
function handleReset() {
|
searchForm.jiance_quyu = undefined;
|
searchForm.weizhi_bianhao = undefined;
|
searchForm.jiejing_jibie = undefined;
|
reload();
|
}
|
function handlePrevMonth() {
|
monthValue.value = currentMonth.value.subtract(1, 'month').format('YYYY-MM');
|
reload();
|
}
|
function handleNextMonth() {
|
monthValue.value = currentMonth.value.add(1, 'month').format('YYYY-MM');
|
reload();
|
}
|
function handleMonthChange(value: null | number | string) {
|
if (!value) return;
|
monthValue.value = dayjs(value).format('YYYY-MM');
|
reload();
|
}
|
function openDetail(row: HuanjingKanbanRow, day: number, event: MouseEvent) {
|
const list = getCellItems(row, day);
|
if (!list.length || !boardRef.value) return;
|
|
const board = boardRef.value;
|
const rect = board.getBoundingClientRect();
|
const width = Math.max(360, Math.min(1080, board.clientWidth - 24));
|
const visibleLeft = board.scrollLeft + 8;
|
const visibleRight = board.scrollLeft + board.clientWidth - width - 8;
|
const desiredLeft = event.clientX - rect.left + board.scrollLeft - 80;
|
const left = Math.max(visibleLeft, Math.min(desiredLeft, visibleRight));
|
const maxHeight = Math.min(420, Math.max(260, board.clientHeight - 24));
|
let top = event.clientY - rect.top + board.scrollTop + 12;
|
if (top + maxHeight > board.scrollTop + board.clientHeight) top = Math.max(board.scrollTop + 8, board.scrollTop + board.clientHeight - maxHeight - 8);
|
|
detailPanel.visible = true;
|
detailPanel.row = row;
|
detailPanel.date = getCellDate(day);
|
detailPanel.items = list;
|
detailPanel.style = { '--detail-max-height': `${maxHeight}px`, left: `${left}px`, maxHeight: `${maxHeight}px`, top: `${top}px`, width: `${width}px` };
|
}
|
function closeDetail() {
|
detailPanel.visible = false;
|
detailPanel.items = [];
|
detailPanel.row = null;
|
}
|
function handleDelete(record: HuanjingKanbanItem) {
|
onlineUtils.sign({
|
metaData: {
|
biz_button: '删除',
|
biz_data: [record],
|
biz_module: '环境监测计划看板',
|
biz_title: [
|
record.jihua_mingcheng,
|
record.jiance_quyu_mingcheng || record.jiance_quyu,
|
record.weizhi_mingcheng,
|
record.weizhi_bianhao,
|
getDictLabel('huanjingJianceXiangmu', record.jiance_xiangmu),
|
].join('/'),
|
is_biz_form: false,
|
is_review_button: false,
|
},
|
onCancel: () => {},
|
onSubmit: async () => {
|
const res = await deleteHuanjingKanbanItem(record.zixiang_id);
|
createMessage.success(res?.msg || '删除成功');
|
await reload();
|
},
|
});
|
}
|
|
onMounted(async () => {
|
loading.value = true;
|
try {
|
await Promise.all([loadDictionaries(), loadAllRows()]);
|
await reload();
|
} finally {
|
loading.value = false;
|
}
|
});
|
</script>
|
|
<template>
|
<div class="jnpf-content-wrapper huanjing-kanban">
|
<div class="jnpf-content-wrapper-center">
|
<div class="kanban-title">环境监测计划看板</div>
|
|
<div class="jnpf-content-wrapper-search-box kanban-search">
|
<div class="search-left">
|
<a-select
|
v-model:value="searchForm.jiance_quyu"
|
:options="areaOptions"
|
allow-clear
|
class="search-select"
|
placeholder="请选择监测区域"
|
show-search
|
@change="validateRoomSelection" />
|
<a-select
|
v-model:value="searchForm.weizhi_bianhao"
|
:options="roomOptions"
|
allow-clear
|
class="search-select"
|
option-filter-prop="label"
|
placeholder="请选择房间号"
|
show-search />
|
<a-select
|
v-model:value="searchForm.jiejing_jibie"
|
:options="levelOptions"
|
allow-clear
|
class="search-select"
|
placeholder="请选择洁净级别"
|
show-search
|
@change="validateRoomSelection" />
|
<a-button type="primary" @click="reload">搜索</a-button>
|
<a-button @click="handleReset">重置</a-button>
|
<a-button :loading="loading" @click="reload">
|
<template #icon>
|
<ReloadOutlined />
|
</template>
|
刷新
|
</a-button>
|
</div>
|
<div class="month-tools">
|
<a-button size="small" @click="handlePrevMonth">上一月</a-button>
|
<jnpf-date-picker v-model:value="monthValue" :allow-clear="false" class="month-picker" format="YYYY-MM" @change="handleMonthChange" />
|
<a-button size="small" @click="handleNextMonth">下一月</a-button>
|
</div>
|
</div>
|
|
<div class="jnpf-content-wrapper-content">
|
<ASpin :spinning="loading">
|
<div ref="boardRef" class="kanban-board" @click="closeDetail" @scroll="closeDetail">
|
<table class="calendar-table" :style="{ '--day-count': days.length }" @click.stop>
|
<thead>
|
<tr>
|
<th class="fixed-col area-col">监测区域</th>
|
<th class="fixed-col room-col">房间/其他名称</th>
|
<th class="fixed-col code-col">房间/其他编号</th>
|
<th class="fixed-col level-col">级别/日期</th>
|
<th v-for="day in days" :key="day" class="day-col" :class="getCellClass(day)">
|
{{ day }}
|
</th>
|
</tr>
|
</thead>
|
<tbody>
|
<tr v-for="row in rows" :key="row.caiyangdian_id || getRowKey(row)">
|
<td class="fixed-col area-col">{{ row.jiance_quyu_mingcheng || row.jiance_quyu }}</td>
|
<td class="fixed-col room-col">{{ row.weizhi_mingcheng }}</td>
|
<td class="fixed-col code-col">{{ row.weizhi_bianhao }}</td>
|
<td class="fixed-col level-col">{{ getDictLabel('huanjingJianceJiejingJibie', row.jiejing_jibie) }}</td>
|
<td v-for="day in days" :key="day" class="day-cell">
|
<button
|
v-if="getCellItems(row, day).length"
|
class="mark-btn"
|
:class="getMarkClass(getCellItems(row, day))"
|
type="button"
|
@click.stop="openDetail(row, day, $event)">
|
{{ getMarkText(getCellItems(row, day)) }}
|
</button>
|
</td>
|
</tr>
|
<tr v-if="!rows.length">
|
<td class="empty-cell" :colspan="days.length + 4">暂无数据</td>
|
</tr>
|
</tbody>
|
</table>
|
|
<div v-if="detailPanel.visible" class="detail-panel" :style="detailPanel.style" @click.stop>
|
<div class="detail-header">
|
<span>{{ detailPanel.date }} {{ detailPanel.row?.weizhi_mingcheng }}</span>
|
<button class="close-btn" type="button" @click="closeDetail">×</button>
|
</div>
|
<div class="detail-table-wrap">
|
<table class="detail-table">
|
<thead>
|
<tr>
|
<th>计划名称</th>
|
<th>计划类型</th>
|
<th>洁净度级别</th>
|
<th>房间名称</th>
|
<th>房间编号</th>
|
<th>监测区域</th>
|
<th>检测项目</th>
|
<th>监测状态</th>
|
<th>状态</th>
|
<th>动作</th>
|
</tr>
|
</thead>
|
<tbody>
|
<tr v-for="record in detailPanel.items" :key="record.zixiang_id">
|
<td>{{ record.jihua_mingcheng }}</td>
|
<td>{{ getDictLabel('huanjingJianceJihuaLeixing', record.jihua_leixing) }}</td>
|
<td>{{ getDictLabel('huanjingJianceJiejingJibie', record.jiejing_jibie) }}</td>
|
<td>{{ record.weizhi_mingcheng }}</td>
|
<td>{{ record.weizhi_bianhao }}</td>
|
<td>{{ record.jiance_quyu_mingcheng || record.jiance_quyu }}</td>
|
<td>{{ getDictLabel('huanjingJianceXiangmu', record.jiance_xiangmu) }}</td>
|
<td>{{ getDictLabel('huanjingJianceZhuangtai', record.jiance_zhuangtai) }}</td>
|
<td>{{ getDictLabel('huanjingJianceJiluZhuangtai', record.biz_status) }}</td>
|
<td>
|
<a-button v-if="record.can_delete" danger type="link" @click="handleDelete(record)">删除</a-button>
|
</td>
|
</tr>
|
</tbody>
|
</table>
|
</div>
|
</div>
|
</div>
|
</ASpin>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<style lang="scss" scoped>
|
.huanjing-kanban {
|
box-sizing: border-box;
|
padding: 10px 12px 12px;
|
background: #fff;
|
|
.jnpf-content-wrapper-center {
|
overflow: hidden;
|
background: #fff;
|
border-radius: 6px;
|
}
|
|
.kanban-search {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
min-height: 54px;
|
padding: 8px 14px;
|
border-top: 1px solid #d9d9d9;
|
border-bottom: 1px solid #d9d9d9;
|
border-radius: 0;
|
gap: 16px;
|
|
.search-left,
|
.month-tools {
|
display: flex;
|
align-items: center;
|
gap: 8px;
|
}
|
|
.search-left {
|
min-width: 0;
|
flex: 1;
|
flex-wrap: wrap;
|
}
|
|
.search-select {
|
width: 170px;
|
}
|
|
.month-tools {
|
flex-shrink: 0;
|
color: #555;
|
}
|
|
.month-picker {
|
width: 120px;
|
}
|
}
|
}
|
|
.kanban-title {
|
height: 66px;
|
color: #111;
|
font-size: 30px;
|
font-weight: 700;
|
line-height: 66px;
|
text-align: center;
|
text-shadow: 0 4px 10px rgb(0 0 0 / 28%);
|
}
|
|
.kanban-board {
|
position: relative;
|
height: calc(100vh - 246px);
|
min-height: 460px;
|
overflow: auto;
|
background: #fff;
|
}
|
|
.calendar-table {
|
--fixed-width: 490px;
|
|
width: 100%;
|
border-spacing: 0;
|
table-layout: fixed;
|
|
th,
|
td {
|
height: 46px;
|
border-right: 1px solid #d6d6d6;
|
border-bottom: 1px solid #d6d6d6;
|
color: #444;
|
font-size: 14px;
|
text-align: center;
|
white-space: nowrap;
|
}
|
|
th {
|
position: sticky;
|
top: 0;
|
z-index: 4;
|
height: 40px;
|
background: #bfbfbf;
|
color: #fff;
|
font-weight: 700;
|
}
|
|
.fixed-col {
|
position: sticky;
|
z-index: 3;
|
background: #fff;
|
}
|
|
thead .fixed-col {
|
z-index: 5;
|
background: #fff;
|
color: #1680bf;
|
}
|
|
.area-col {
|
left: 0;
|
width: 110px;
|
min-width: 110px;
|
}
|
|
.room-col {
|
left: 110px;
|
width: 180px;
|
min-width: 180px;
|
}
|
|
.code-col {
|
left: 290px;
|
width: 110px;
|
min-width: 110px;
|
}
|
|
.level-col {
|
left: 400px;
|
width: 90px;
|
min-width: 90px;
|
}
|
|
.day-col,
|
.day-cell {
|
width: calc((100% - var(--fixed-width)) / var(--day-count));
|
}
|
|
.day-col.is-today {
|
background: #f59d14;
|
color: #666;
|
}
|
|
.empty-cell {
|
height: 120px;
|
color: #999;
|
}
|
}
|
|
.mark-btn {
|
width: 100%;
|
max-width: 28px;
|
height: 28px;
|
padding: 0;
|
border: 0;
|
background: transparent;
|
cursor: pointer;
|
font-size: 18px;
|
font-weight: 700;
|
line-height: 28px;
|
|
&.is-enabled {
|
color: #1680bf;
|
}
|
|
&.is-done {
|
color: #1680bf;
|
font-size: 24px;
|
}
|
|
&.is-pending {
|
color: #f59d14;
|
}
|
}
|
|
.detail-panel {
|
position: absolute;
|
z-index: 20;
|
display: flex;
|
flex-direction: column;
|
overflow: hidden;
|
border: 1px solid #b8cce0;
|
background: #fff;
|
box-shadow: 0 8px 20px rgb(0 0 0 / 12%);
|
}
|
|
.detail-header {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
height: 32px;
|
padding: 0 10px;
|
border-bottom: 1px solid #d6d6d6;
|
color: #333;
|
font-weight: 600;
|
}
|
|
.close-btn {
|
width: 24px;
|
height: 24px;
|
border: 0;
|
background: transparent;
|
color: #666;
|
cursor: pointer;
|
font-size: 18px;
|
line-height: 24px;
|
}
|
|
.detail-table-wrap {
|
flex: 1;
|
min-height: 0;
|
max-height: calc(var(--detail-max-height, 420px) - 32px);
|
overflow: auto;
|
}
|
|
.detail-table {
|
width: 100%;
|
min-width: 1040px;
|
border-spacing: 0;
|
|
th,
|
td {
|
height: 42px;
|
padding: 0 8px;
|
border-right: 1px solid #d6d6d6;
|
border-bottom: 1px solid #d6d6d6;
|
color: #3d3d3d;
|
font-size: 14px;
|
text-align: center;
|
white-space: nowrap;
|
}
|
|
th {
|
position: sticky;
|
top: 0;
|
z-index: 1;
|
background: #f7f7f7;
|
font-weight: 700;
|
}
|
}
|
|
@media (max-width: 1200px) {
|
.kanban-search {
|
align-items: flex-start;
|
flex-direction: column;
|
|
.month-tools {
|
align-self: flex-end;
|
}
|
}
|
}
|
</style>
|