<script lang="ts" setup>
|
import type { CSSProperties } from 'vue';
|
|
import type { SxtKanbanItem, SxtKanbanOptions, SxtKanbanQuery, SxtKanbanRow } from '#/api/x/lims/shiyanshi/sxt';
|
|
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 { cancelSxtKanbanRenwu, getSxtKanbanItems, getSxtKanbanOptions, getSxtKanbanRows } from '#/api/x/lims/shiyanshi/sxt';
|
import { onlineUtils } from '#/utils/jnpf';
|
|
import { buildTaskCellMap, getTaskCellState, getTaskDisplayStatus, getTaskRowKey } from './helpers/waterPlanBoard';
|
|
defineOptions({ name: 'XlimsShiyanshiSxtJihuaKanban' });
|
|
interface DetailPanel {
|
date: string;
|
items: SxtKanbanItem[];
|
row: null | SxtKanbanRow;
|
style: CSSProperties & Record<string, string>;
|
visible: boolean;
|
}
|
|
const { createConfirm, createMessage } = useMessage();
|
const loading = ref(false);
|
const monthValue = ref(dayjs().format('YYYY-MM'));
|
const rows = ref<SxtKanbanRow[]>([]);
|
const items = ref<SxtKanbanItem[]>([]);
|
const kanbanOptions = ref<SxtKanbanOptions>(createEmptyOptions());
|
const boardRef = ref<HTMLElement>();
|
const searchForm = reactive({
|
yangpin_leixing: undefined as string | undefined,
|
suoshu_chejian: undefined as string | undefined,
|
quyang_didian: undefined as string | undefined,
|
quyangdian_bianhao: undefined as string | undefined,
|
});
|
const detailPanel = reactive<DetailPanel>({ date: '', items: [], row: null, style: {}, visible: false });
|
|
const month = computed(() => dayjs(monthValue.value).startOf('month'));
|
const days = computed(() => Array.from({ length: month.value.daysInMonth() }, (_, index) => index + 1));
|
const today = dayjs().format('YYYY-MM-DD');
|
const itemMap = computed(() => buildTaskCellMap(items.value));
|
const typeOptions = computed(() => toSelectOptions(kanbanOptions.value.yangpin_leixing_list));
|
const workshopOptions = computed(() => toSelectOptions(kanbanOptions.value.chejian_list));
|
const placeOptions = computed(() => toSelectOptions(kanbanOptions.value.quyang_didian_list));
|
const pointOptions = computed(() => toSelectOptions(kanbanOptions.value.quyangdian_list));
|
let optionsRequestId = 0;
|
|
function createEmptyOptions(): SxtKanbanOptions {
|
return { chejian_list: [], quyang_didian_list: [], quyangdian_list: [], yangpin_leixing_list: [] };
|
}
|
function toSelectOptions(options: Array<{ id: string; name: string }>) {
|
return options.map(({ id, name }) => ({ label: name, value: id }));
|
}
|
function query(extra: Record<string, string> = {}) {
|
const data: Record<string, string | undefined> = { ...searchForm, ...extra };
|
Object.keys(data).forEach((key) => {
|
if (!data[key]) delete data[key];
|
});
|
return data;
|
}
|
function cellDate(day: number) {
|
return month.value.date(day).format('YYYY-MM-DD');
|
}
|
function cellItems(row: SxtKanbanRow, day: number) {
|
return itemMap.value.get(`${getTaskRowKey(row)}|${cellDate(day)}`) || [];
|
}
|
function closeDetail() {
|
detailPanel.visible = false;
|
detailPanel.items = [];
|
detailPanel.row = null;
|
}
|
function openDetail(row: SxtKanbanRow, day: number, event: MouseEvent) {
|
const list = cellItems(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 = cellDate(day);
|
detailPanel.items = list;
|
detailPanel.style = { '--detail-max-height': `${maxHeight}px`, left: `${left}px`, maxHeight: `${maxHeight}px`, top: `${top}px`, width: `${width}px` };
|
}
|
async function reload() {
|
loading.value = true;
|
closeDetail();
|
try {
|
const [rowRes, itemRes] = await Promise.all([
|
getSxtKanbanRows(query()),
|
getSxtKanbanItems(query({ start_date: month.value.format('YYYY-MM-DD'), end_date: month.value.endOf('month').format('YYYY-MM-DD') })),
|
]);
|
rows.value = rowRes?.data || [];
|
items.value = itemRes?.data || [];
|
} finally {
|
loading.value = false;
|
}
|
}
|
async function refreshOptions(data: Pick<SxtKanbanQuery, 'quyang_didian' | 'suoshu_chejian' | 'yangpin_leixing'> = {}) {
|
const requestId = ++optionsRequestId;
|
const res = await getSxtKanbanOptions(data);
|
if (requestId === optionsRequestId) kanbanOptions.value = res?.data || createEmptyOptions();
|
}
|
async function reset() {
|
searchForm.yangpin_leixing = undefined;
|
searchForm.suoshu_chejian = undefined;
|
searchForm.quyang_didian = undefined;
|
searchForm.quyangdian_bianhao = undefined;
|
kanbanOptions.value = createEmptyOptions();
|
await Promise.all([refreshOptions(), reload()]);
|
}
|
async function changeType() {
|
searchForm.suoshu_chejian = undefined;
|
searchForm.quyang_didian = undefined;
|
searchForm.quyangdian_bianhao = undefined;
|
kanbanOptions.value = createEmptyOptions();
|
await refreshOptions(searchForm.yangpin_leixing ? { yangpin_leixing: searchForm.yangpin_leixing } : {});
|
}
|
async function changeWorkshop() {
|
searchForm.quyang_didian = undefined;
|
searchForm.quyangdian_bianhao = undefined;
|
kanbanOptions.value = createEmptyOptions();
|
if (!searchForm.yangpin_leixing) return;
|
await refreshOptions({ suoshu_chejian: searchForm.suoshu_chejian, yangpin_leixing: searchForm.yangpin_leixing });
|
}
|
async function changePlace() {
|
searchForm.quyangdian_bianhao = undefined;
|
kanbanOptions.value = createEmptyOptions();
|
if (!searchForm.yangpin_leixing || !searchForm.suoshu_chejian) return;
|
await refreshOptions({
|
quyang_didian: searchForm.quyang_didian,
|
suoshu_chejian: searchForm.suoshu_chejian,
|
yangpin_leixing: searchForm.yangpin_leixing,
|
});
|
}
|
function previous() {
|
monthValue.value = month.value.subtract(1, 'month').format('YYYY-MM');
|
reload();
|
}
|
function next() {
|
monthValue.value = month.value.add(1, 'month').format('YYYY-MM');
|
reload();
|
}
|
function cancelRenwu(item: SxtKanbanItem) {
|
createConfirm({
|
title: '提示',
|
content: '确定取消该日期未生成请验单的任务?',
|
iconType: 'warning',
|
onOk: () => {
|
onlineUtils.sign({
|
metaData: {
|
biz_button: '取消',
|
biz_data: [item],
|
biz_module: '水系统计划看板',
|
biz_title: [item.jihua_mingcheng, item.quyang_didian, item.quyangdian_bianhao, item.jianyan_xiangmu_mingcheng || item.jianyan_xiangmu]
|
.filter(Boolean)
|
.join('/'),
|
is_biz_form: false,
|
is_review_button: false,
|
},
|
onCancel: () => {},
|
onSubmit: async (signData) => {
|
const res = await cancelSxtKanbanRenwu(item.renwu_id, { biz_sign: signData?.biz_sign });
|
createMessage.success(res?.msg || '取消成功');
|
await reload();
|
},
|
});
|
},
|
});
|
}
|
onMounted(async () => {
|
loading.value = true;
|
try {
|
await Promise.all([refreshOptions(), reload()]);
|
} finally {
|
loading.value = false;
|
}
|
});
|
</script>
|
|
<template>
|
<div class="jnpf-content-wrapper sxt-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">
|
<div class="search-filters">
|
<a-select
|
v-model:value="searchForm.yangpin_leixing"
|
:options="typeOptions"
|
allow-clear
|
class="search-select"
|
option-filter-prop="label"
|
placeholder="请选择样品类型"
|
show-search
|
@change="changeType" />
|
<a-select
|
v-model:value="searchForm.suoshu_chejian"
|
:disabled="!searchForm.yangpin_leixing"
|
:options="workshopOptions"
|
allow-clear
|
class="search-select"
|
option-filter-prop="label"
|
placeholder="请选择所属车间"
|
show-search
|
@change="changeWorkshop" />
|
<a-select
|
v-model:value="searchForm.quyang_didian"
|
:disabled="!searchForm.suoshu_chejian"
|
:options="placeOptions"
|
allow-clear
|
class="search-select"
|
option-filter-prop="label"
|
placeholder="请选择取样地点"
|
show-search
|
@change="changePlace" />
|
<a-select
|
v-model:value="searchForm.quyangdian_bianhao"
|
:disabled="!searchForm.quyang_didian"
|
:options="pointOptions"
|
allow-clear
|
class="search-select"
|
option-filter-prop="label"
|
placeholder="请选择取样点编号"
|
show-search />
|
</div>
|
<div class="search-actions">
|
<a-button type="primary" @click="reload">搜索</a-button>
|
<a-button @click="reset">重置</a-button>
|
<a-button :loading="loading" @click="reload">
|
<template #icon>
|
<ReloadOutlined />
|
</template>
|
刷新
|
</a-button>
|
</div>
|
</div>
|
<div class="month-tools">
|
<a-button size="small" @click="previous">上一月</a-button>
|
<jnpf-date-picker v-model:value="monthValue" :allow-clear="false" class="month-picker" format="YYYY-MM" @change="reload" />
|
<a-button size="small" @click="next">下一月</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 type-col">样品类型</th>
|
<th class="fixed-col workshop-col">所属车间</th>
|
<th class="fixed-col place-col">取样地点</th>
|
<th class="fixed-col point-col">取样点编号</th>
|
<th v-for="day in days" :key="day" class="day-col" :class="{ 'is-today': cellDate(day) === today }">
|
{{ day }}
|
</th>
|
</tr>
|
</thead>
|
<tbody>
|
<tr v-for="row in rows" :key="getTaskRowKey(row)">
|
<td class="fixed-col type-col">{{ row.yangpin_leixing_mingcheng || row.yangpin_leixing }}</td>
|
<td class="fixed-col workshop-col">{{ row.suoshu_chejian }}</td>
|
<td class="fixed-col place-col">{{ row.quyang_didian }}</td>
|
<td class="fixed-col point-col">{{ row.quyangdian_bianhao }}</td>
|
<td v-for="day in days" :key="day" class="day-cell">
|
<button
|
v-if="cellItems(row, day).length"
|
class="mark-btn"
|
:class="getTaskCellState(cellItems(row, day))"
|
type="button"
|
@click.stop="openDetail(row, day, $event)">
|
{{ getTaskCellState(cellItems(row, day)) === 'done' ? '✓' : '!' }}
|
</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?.quyangdian_bianhao }}</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>
|
</tr>
|
</thead>
|
<tbody>
|
<tr v-for="item in detailPanel.items" :key="item.renwu_id">
|
<td>{{ item.jihua_mingcheng }}</td>
|
<td>{{ item.quyangdian_bianhao }}</td>
|
<td>{{ item.quyang_didian }}</td>
|
<td>{{ item.jianyan_xiangmu_mingcheng || item.jianyan_xiangmu }}</td>
|
<td>{{ getTaskDisplayStatus(item) }}</td>
|
<td><a-button v-if="item.can_delete" danger type="link" @click="cancelRenwu(item)">取消</a-button></td>
|
</tr>
|
</tbody>
|
</table>
|
</div>
|
</div>
|
</div>
|
</ASpin>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<style lang="scss" scoped>
|
.sxt-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;
|
gap: 16px;
|
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;
|
|
.search-left,
|
.search-actions,
|
.month-tools {
|
display: flex;
|
gap: 8px;
|
align-items: center;
|
}
|
|
.search-left {
|
flex: 1;
|
min-width: 0;
|
}
|
|
.search-filters {
|
display: grid;
|
flex: 1;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
gap: 8px;
|
min-width: 0;
|
}
|
|
.search-actions {
|
flex-shrink: 0;
|
white-space: nowrap;
|
}
|
|
.search-select {
|
width: 100%;
|
min-width: 0;
|
}
|
|
.month-tools {
|
flex-shrink: 0;
|
color: #555;
|
}
|
|
.month-picker {
|
width: 120px;
|
}
|
}
|
}
|
|
.kanban-title {
|
height: 66px;
|
font-size: 30px;
|
font-weight: 700;
|
line-height: 66px;
|
color: #111;
|
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%;
|
table-layout: fixed;
|
border-spacing: 0;
|
|
th,
|
td {
|
height: 46px;
|
font-size: 14px;
|
color: #444;
|
text-align: center;
|
white-space: nowrap;
|
border-right: 1px solid #d6d6d6;
|
border-bottom: 1px solid #d6d6d6;
|
}
|
|
th {
|
position: sticky;
|
top: 0;
|
z-index: 4;
|
height: 40px;
|
font-weight: 700;
|
color: #fff;
|
background: #bfbfbf;
|
}
|
|
.fixed-col {
|
position: sticky;
|
z-index: 3;
|
background: #fff;
|
}
|
|
thead .fixed-col {
|
z-index: 5;
|
color: #1680bf;
|
background: #fff;
|
}
|
|
.type-col {
|
left: 0;
|
width: 130px;
|
min-width: 130px;
|
}
|
|
.workshop-col {
|
left: 130px;
|
width: 130px;
|
min-width: 130px;
|
}
|
|
.place-col {
|
left: 260px;
|
width: 130px;
|
min-width: 130px;
|
}
|
|
.point-col {
|
left: 390px;
|
width: 100px;
|
min-width: 100px;
|
}
|
|
.day-col,
|
.day-cell {
|
width: calc((100% - var(--fixed-width)) / var(--day-count));
|
}
|
|
.day-col.is-today {
|
color: #666;
|
background: #f59d14;
|
}
|
|
.empty-cell {
|
height: 120px;
|
color: #999;
|
}
|
}
|
|
.mark-btn {
|
width: 100%;
|
max-width: 28px;
|
height: 28px;
|
padding: 0;
|
font-size: 18px;
|
font-weight: 700;
|
line-height: 28px;
|
cursor: pointer;
|
background: transparent;
|
border: 0;
|
|
&.done {
|
font-size: 24px;
|
color: #1680bf;
|
}
|
|
&.pending {
|
color: #f59d14;
|
}
|
|
&.blocked {
|
color: #d9363e;
|
}
|
}
|
|
.detail-panel {
|
position: absolute;
|
z-index: 20;
|
display: flex;
|
flex-direction: column;
|
overflow: hidden;
|
background: #fff;
|
border: 1px solid #b8cce0;
|
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;
|
font-weight: 600;
|
color: #333;
|
border-bottom: 1px solid #d6d6d6;
|
}
|
|
.close-btn {
|
width: 24px;
|
height: 24px;
|
font-size: 18px;
|
line-height: 24px;
|
color: #666;
|
cursor: pointer;
|
background: transparent;
|
border: 0;
|
}
|
|
.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: 760px;
|
border-spacing: 0;
|
|
th,
|
td {
|
height: 42px;
|
padding: 0 8px;
|
font-size: 14px;
|
color: #3d3d3d;
|
text-align: center;
|
white-space: nowrap;
|
border-right: 1px solid #d6d6d6;
|
border-bottom: 1px solid #d6d6d6;
|
}
|
|
th {
|
position: sticky;
|
top: 0;
|
z-index: 1;
|
font-weight: 700;
|
background: #f7f7f7;
|
}
|
}
|
|
@media (max-width: 1200px) {
|
.kanban-search {
|
flex-direction: column;
|
align-items: flex-start;
|
|
.search-left {
|
width: 100%;
|
}
|
|
.month-tools {
|
align-self: flex-end;
|
}
|
}
|
}
|
|
@media (max-width: 768px) {
|
.kanban-search {
|
.search-left {
|
flex-direction: column;
|
align-items: stretch;
|
}
|
|
.search-filters {
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
}
|
|
.search-actions {
|
align-self: flex-end;
|
}
|
}
|
}
|
</style>
|