Files
Docker/WebVue/src/components/basic/SearchCard.vue
T
2026-09-17 11:49:35 +08:00

877 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div
ref="searchRef"
style="margin-left: 4px; padding-top: 4px; margin-right: 12px"
>
<searchCriteria
@submit="onSubmit"
@reset="onReset"
@handle-change-bind-value="handleChangeBindValue"
>
<template #control>
<slot name="control"></slot>
</template>
</searchCriteria>
<hr />
</div>
<div ref="settingRef" class="setting">
<a-row :wrap="false">
<!-- 大屏用 4 列占位把操作区推到右侧;中小屏占位列隐藏,内容列占满整行再靠右 -->
<a-col :xs="0" :sm="0" :md="0" :lg="4"></a-col>
<a-col class="sc-toolbar" :xs="24" :sm="24" :md="24" :lg="20">
<a-input
flex="none"
readonly
value="视图"
class="site-input-split sc-view-label"
style="
width: 80px;
border-radius: 0;
border-top-left-radius: 5pt 5pt;
border-bottom-left-radius: 5pt 5pt;
background-color: rgb(250, 250, 250);
pointer-events: none;
"
/>
<a-select
class="sc-view-select"
v-model:value="viewOption.Value"
@select="onSelectColumn"
>
<a-select-option
v-for="o in viewOption.SelectOptions"
:key="o.DisplayName"
:value="o.BindValue"
>{{ o.DisplayName }}</a-select-option
>
</a-select>
<a-button
class="sc-toolbar-btn"
type="dashed"
:icon="h(SettingTwoTone)"
@click.prevent="onViewResult"
></a-button>
<a-button
class="sc-toolbar-btn"
type="dashed"
:icon="h(PrinterTwoTone)"
@click.prevent="onPrint"
></a-button>
</a-col>
</a-row>
<columnModal
:show-modal="showColumnModal"
@on-change-columns="onChangeColumns"
@handle-close="onHandleClose"
@on-change-view-option="onChangeViewOption"
/>
</div>
<!-- ==================== 结果区:卡片列表 ==================== -->
<div class="sc-result" style="margin-left: 2px; margin-right: 6px">
<!-- 卡片列表 -->
<div
ref="listRef"
class="sc-list"
:style="isModal ? undefined : { height: resultH || undefined }"
>
<a-spin :spinning="loading">
<div v-if="datas.length" class="sc-list-inner">
<div
v-for="(record, index) in datas"
:key="index"
class="sc-card"
:class="{
'sc-card-selected': isSelected(record),
'sc-card-collapsed': !isExpanded(index),
}"
>
<div
class="sc-card-head"
@click="onCardHeadClick($event, record, index)"
@dblclick="onCardDblclick($event, record)"
>
<span class="sc-check" @click.stop="onCardClick($event, record)">
<a-checkbox v-if="isCheckbox" :checked="isSelected(record)" />
<a-radio v-else :checked="isSelected(record)" />
</span>
<span class="sc-index">{{ getRowIndex(index) }}</span>
<span class="sc-title"><slot
name="cardTitle"
v-bind="{ record: record, index: index }"
>{{ getTitle(record) }}</slot></span>
<RightOutlined
class="sc-arrow"
:class="{ 'sc-arrow-open': isExpanded(index) }"
/>
</div>
<div v-show="isExpanded(index)" class="sc-card-body">
<div
v-for="column in allColumns"
:key="column.dataIndex"
class="sc-field"
>
<span class="sc-field-label"><slot
name="headerCell"
v-bind="{ title: column.title, column: column }"
>{{ column.title }}</slot></span>
<span class="sc-field-value"><span
class="sc-slot"
><slot
name="bodyCell"
v-bind="{
text: record[column.dataIndex],
column: column,
record: record,
index: index,
}"
></slot></span><span class="sc-fallback">{{
formatCell(record, column)
}}</span></span>
</div>
</div>
</div>
</div>
<a-empty v-else class="sc-empty" description="暂无数据" />
</a-spin>
</div>
<!-- 分页:固定在 footer 上方 -->
<div
ref="paginationRef"
class="sc-pagination"
:class="{ 'sc-pagination-fixed': !isModal }"
:style="!isModal ? { bottom: paginationBottom } : undefined"
>
<a-pagination
size="small"
:current="pageCurrent"
:page-size="pageSizeCurrent"
:total="paginationJ.Total"
:show-size-changer="true"
:page-size-options="['10', '20', '50', '100']"
:show-total="showTotal"
@change="onPageChange"
/>
</div>
</div>
<slot name="footer" style="padding-left: 4px"></slot>
</template>
<script setup lang="ts">
import {
watch,
inject,
computed,
onMounted,
h,
ref,
unref,
nextTick,
onUnmounted,
} from "vue";
import { usePagination } from "vue-request";
import searchCriteria from "@/components/basic/SearchCriteria.vue";
import { SearchFilter } from "@/data/auto/searchFilter";
import { SearchCriteriaResultData } from "@/data/auto/searchCriteriaResultData";
import {
SettingTwoTone,
PrinterTwoTone,
RightOutlined,
} from "@ant-design/icons-vue";
import columnModal from "./ColumnModal.vue";
import { useWindowSize } from "@vueuse/core";
import { bsInfo } from "@/utils/browser";
import { enquiryMethodKey } from "@/injections";
//================props================
const props = defineProps({
isModal: {
type: Boolean,
default: false,
},
loading: {
type: Boolean,
default: false,
},
scrollx: {
type: Number,
default: 1024,
},
scrolly: {
type: Number,
default: 0,
},
pagination: {
type: SearchCriteriaResultData,
default: new SearchCriteriaResultData(),
},
rowSelectionType: {
type: String,
default: "radio",
},
// 卡片标题取值的列(按 dataIndex),默认取第一个字段列
cardTitleKeys: {
type: Array as () => string[],
default: () => [],
},
});
const isModal = ref(props["isModal"]);
const paginationJ = ref(props["pagination"]);
const rowSelectionType = ref(props["rowSelectionType"]);
const tableY = ref(props["scrolly"]);
const enquiry = inject(enquiryMethodKey, undefined);
const datas = enquiry?.getDatas()!;
const viewOption = ref(
enquiry
? enquiry.getViewOption()
: {
Value: "default",
SelectOptions: [{ BindValue: "default", DisplayName: "默认" }],
Columns: [{ BindValue: "default", BindColumn: [] }],
},
);
//================inject================
const isSM = ref<boolean>(false);
const { width, height } = useWindowSize();
if (bsInfo.size.sm) {
isSM.value = true;
} else {
isSM.value = false;
}
const resultH = ref("");
const showColumnModal = ref<boolean>(false);
const selected = ref<any[]>([]);
const emit = defineEmits([
"onReset",
"onSubmit",
"handleChangeBindValue",
"usePagination",
"onRowDblclick",
"onclick",
"onSelectChange",
"onViewResult",
"onPrint",
"updateColumns",
]);
const onViewResult = () => {
showColumnModal.value = true;
emit("onViewResult");
};
const onHandleClose = () => {
showColumnModal.value = false;
};
const onPrint = () => {
emit("onPrint", viewOption.value);
};
const searchRef = ref<HTMLElement | null>();
const settingRef = ref<HTMLElement | null>();
//================分页固定:始终贴在 footer 上方================
const paginationBottom = ref("64px");
const paginationRef = ref<HTMLElement | null>(null);
const listRef = ref<HTMLElement | null>(null);
const modalBodyEl = ref<HTMLElement | null>(null);
/** 获取页面级 footer(SearchCard 所在容器内的 .page-footer) */
const getPageFooter = (): HTMLElement | null => {
const container = searchRef.value?.parentElement ?? document.body;
return container.querySelector(".page-footer") as HTMLElement | null;
};
/** 普通页面:按 footer 真实高度算出分页栏的 bottom 偏移 */
const computePaginationBottom = () => {
if (isModal.value) return;
const footerEl = getPageFooter();
if (!footerEl) {
paginationBottom.value = "64px";
return;
}
// 视口底部 -> footer 顶部的距离(含 footer 高度与其 bottom 偏移) + 间距
const offset = window.innerHeight - footerEl.getBoundingClientRect().top;
paginationBottom.value = `${Math.max(Math.round(offset) + 6, 0)}px`;
};
/** 普通页面:底部为「分页栏 + 页面 footer」预留的真实高度(列表直接顶到分页栏上沿) */
const getFooterReservedHeight = (): number => {
const bottom = Number.parseInt(paginationBottom.value, 10);
const offset = Number.isFinite(bottom) ? bottom : 64;
// 分页栏自身高度(首帧未挂载时按 34 估算)
const paginationH = paginationRef.value?.offsetHeight || 34;
return offset + paginationH + 6;
};
/** 弹窗模式:给 modal 设定高度,使内容区撑满(内部 flex 生效) */
const applyModalHeight = () => {
const bodyEl = modalBodyEl.value;
if (!bodyEl) return;
const modalEl = bodyEl.closest(".ant-modal") as HTMLElement | null;
if (!modalEl) return;
const h = Math.max(window.innerHeight - 140, 240);
modalEl.style.height = `${h}px`;
};
/** 弹窗模式:让 modal body 变成纵向 flex 容器,分页贴底(modal footer 上方) */
const setupModalLayout = async () => {
if (!isModal.value) return;
await nextTick();
const bodyEl = searchRef.value?.closest(
".ant-modal-body",
) as HTMLElement | null;
if (!bodyEl) return;
modalBodyEl.value = bodyEl;
bodyEl.classList.add("sc-modal-body");
applyModalHeight();
};
/** 统一重新布局 */
const relayout = async () => {
// 先定位分页栏 / 弹窗,等 DOM 位置生效后再反推列表高度,避免用到上一帧位置
computePaginationBottom();
applyModalHeight();
await nextTick();
computResultTableHeight();
};
// 增加监听窗口宽高变化,窗口缩放自动重算
watch([width, height, isModal, tableY], () => {
void relayout();
});
const HEIGHT_OFFSETS = {
minTableHeight: 120, // 列表最小高度,防止计算结果为负
};
let resizeObserver: ResizeObserver | null = null;
/** 列表高度:优先用「列表顶部 → 分页栏顶部」的真实距离,保证滚动条紧贴页码 */
const computResultTableHeight = () => {
// 弹窗内高度由 flex 撑满,无需计算
if (isModal.value) return;
const listEl = listRef.value;
const paginationEl = paginationRef.value;
let finalY: number;
if (listEl && paginationEl) {
// 两者同为视口坐标,其差值就是真实可用高度(与页面滚动无关)
finalY =
paginationEl.getBoundingClientRect().top -
listEl.getBoundingClientRect().top -
6;
} else {
// 兜底:DOM 尚未就绪时沿用估算方式
const viewportH = window.innerHeight;
const searchHeight = searchRef.value?.offsetHeight ?? 0;
const settingHeight = settingRef.value?.offsetHeight ?? 0;
const base = tableY.value > 0 ? tableY.value : settingHeight;
finalY = viewportH - searchHeight - base - getFooterReservedHeight();
}
// 最小高度兜底,不能小于minTableHeight
finalY = Math.max(finalY, HEIGHT_OFFSETS.minTableHeight);
resultH.value = `${finalY}px`; // 直接输出px,不再calc
};
// 重新绑定observer,处理v‑if动态创建的setting DOM
const rebindObserver = () => {
if (!resizeObserver) return;
// 先全部取消
resizeObserver.disconnect();
if (searchRef.value) resizeObserver.observe(searchRef.value);
if (settingRef.value) resizeObserver.observe(settingRef.value);
if (paginationRef.value) resizeObserver.observe(paginationRef.value);
const footerEl = getPageFooter();
if (footerEl) resizeObserver.observe(footerEl);
void relayout();
};
onMounted(() => {
resizeObserver = new ResizeObserver(() => {
void relayout();
});
// 弹窗形态未显式传 is-modal 时自动识别:位于 .ant-modal-body 内即按弹窗布局
// 否则分页栏会按「页面模式」position:fixed 钉在视口底部,压住弹窗/页面 footer
if (!isModal.value && searchRef.value?.closest(".ant-modal-body")) {
isModal.value = true;
}
rebindObserver();
void setupModalLayout();
});
onUnmounted(() => {
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
});
// ✅监听v‑if条件变化,条件一变,重新绑定观测器
watch([isModal, isSM], () => {
rebindObserver();
void setupModalLayout();
});
watch([width, height, tableY], () => {
void relayout();
});
defineOptions({
name: "searchCard",
});
const columns = ref(
viewOption.value.Columns[0].BindColumn?.filter((x: any) => x.hidden != "Y"),
);
//接收拖动变化
const onChangeColumns = (cls: Column[]) => {
columns.value = cls?.filter((x) => x.hidden != "Y");
};
const onChangeViewOption = (vop: ViewOption) => {
viewOption.value = vop;
};
const onSelectColumn = () => {
columns.value = viewOption.value.Columns.filter(
(x: any) => x.BindValue == viewOption.value.Value,
)[0].BindColumn?.filter((x: any) => x.hidden != "Y") as Column[];
};
const onSubmit = async (filters: SearchFilter[]) => {
emit("onSubmit", filters);
};
const handleChangeBindValue = (bindValue: string, value: string) => {
emit("handleChangeBindValue", bindValue, value);
};
const onReset = async () => {
datas.value = [];
selected.value = [];
emit("onReset");
};
//================卡片列表数据================
// 字段列(排除序号列)
const fieldColumns = computed(() =>
((columns.value ?? []) as any[]).filter((c: any) => c.dataIndex !== "index"),
);
// 卡片标题使用的列
const titleColumns = computed(() => {
const keys = props.cardTitleKeys ?? [];
if (keys.length) {
return keys
.map((k) => fieldColumns.value.find((c: any) => c.dataIndex === k))
.filter(Boolean) as any[];
}
return fieldColumns.value.slice(0, 1);
});
// 卡片主体展示的列:完整展示全部字段列(含用于标题的列),保证不丢列
const allColumns = computed(() => fieldColumns.value);
// 展开状态(按列表序号记录):默认全部收起,只显示标题行
const expandedSet = ref<Record<number, boolean>>({});
const isExpanded = (index: number | string) =>
!!expandedSet.value[Number(index)];
const toggleExpand = (index: number | string) => {
const key = Number(index);
expandedSet.value = {
...expandedSet.value,
[key]: !expandedSet.value[key],
};
};
// 点击标题行:仅切换字段明细的展开/收起,不改选中状态
const onCardHeadClick = (event: any, record: any, index: number | string) => {
toggleExpand(index);
};
// 数据变化(查询/分页/排序)后重置展开状态
watch(datas, () => {
expandedSet.value = {};
});
const getTitle = (record: any) => {
const parts = titleColumns.value
.map((c: any) => record?.[c.dataIndex])
.filter((v: any) => v !== null && v !== undefined && v !== "");
return parts.length ? parts.join(" ") : "-";
};
const formatCell = (record: any, column: any) => {
const v = record?.[column.dataIndex];
return v === null || v === undefined || v === "" ? "-" : v;
};
const getRowIndex = (index: number | string) =>
(pageCurrent.value - 1) * pageSizeCurrent.value + Number(index) + 1;
//================选择================
const isCheckbox = computed(() => unref(rowSelectionType) === "checkbox");
const isSelected = (record: any) => selected.value.includes(record);
const emitSelection = () => emit("onSelectChange", selected.value);
const onCardClick = (event: any, record: any) => {
if (unref(rowSelectionType) == "radio") {
selected.value = [record];
} else {
if (selected.value.includes(record)) {
selected.value = selected.value.filter((item) => item !== record);
} else {
selected.value.push(record);
}
}
emit("onclick", event, [record]);
emitSelection();
};
const onCardDblclick = (event: any, record: any) => {
if (selected.value.includes(record)) {
selected.value = selected.value.filter((item) => item !== record);
} else {
selected.value.push(record);
}
emit("onRowDblclick", event, record);
emitSelection();
};
//================分页================
const pageCurrent = ref<number>(paginationJ.value.Page || 1);
const pageSizeCurrent = ref<number>(paginationJ.value.Limit || 20);
watch(
() => [paginationJ.value.Page, paginationJ.value.Limit],
() => {
pageCurrent.value = paginationJ.value.Page || 1;
pageSizeCurrent.value = paginationJ.value.Limit || 20;
},
);
const showTotal = (total: number) => `共 ${total} 条`;
const onPageChange = (page: number, pageSize: number) => {
pageCurrent.value = page;
pageSizeCurrent.value = pageSize;
run({ page, pageSize });
};
const queryData = async (params: PagingParams): Promise<any> => {
try {
emit("usePagination", params);
} catch (err) {}
return datas.value;
};
const { run } = usePagination(queryData, {
pagination: {
currentKey: "page",
pageSizeKey: "pageSize",
totalKey: "totalCount",
},
});
</script>
<style lang="less">
.search-criteria .ant-select-single .ant-select-selector {
border-top-left-radius: 0pt 0pt;
border-bottom-left-radius: 0pt 0pt;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0 !important;
}
.search-criteria .ant-select-multiple .ant-select-selector {
border-top-left-radius: 0pt 0pt;
border-bottom-left-radius: 0pt 0pt;
border-top-right-radius: 5pt 5pt;
border-bottom-right-radius: 5pt 5pt;
border-left: 0 !important;
}
.search-criteria .ant-picker {
width: 100%;
}
.setting {
flex: 0 0 auto;
margin-right: 12px;
margin-bottom: 4px;
}
.setting .ant-select-selector {
border-radius: 0;
border-left: 0 !important;
}
.setting .ant-btn {
padding-top: 4px;
padding-bottom: 4px;
border-radius: 0;
}
/* 视图工具条:强制单行,下拉框自适应剩余宽度,按钮不被挤到下一行 */
.setting .sc-toolbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-end;
min-width: 0;
}
.setting .sc-toolbar .sc-view-label,
.setting .sc-toolbar .sc-view-select,
.setting .sc-toolbar .sc-toolbar-btn {
flex: 0 0 auto;
}
/* 下拉框吃掉中间的剩余宽度,窄屏也不会把按钮顶下去 */
.setting .sc-toolbar .sc-view-select {
flex: 1 1 auto;
width: auto;
min-width: 120px;
}
/* ==================== 结果区:卡片列表 ==================== */
.sc-list {
overflow-x: hidden;
overflow-y: auto;
margin-top: 4px;
padding: 2px;
-webkit-overflow-scrolling: touch;
}
.sc-list-inner {
display: flex;
flex-direction: column;
gap: 10px;
}
.sc-card {
border: 1px solid #eef0f4;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
cursor: pointer;
user-select: none;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.sc-card-selected {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
}
.sc-card-head {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #eef0f4;
border-radius: 12px 12px 0 0;
background: #f7f9fc;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
/* 标题再长也不外溢,多余部分由标题自身省略 */
overflow: hidden;
}
/* 收起时:标题行独占整卡,隐藏与明细区的分割线 */
.sc-card-collapsed .sc-card-head {
border-bottom-color: transparent;
border-radius: 12px;
}
/* 右侧展开箭头:展开后顺时针旋转 90 度 */
.sc-arrow {
flex: 0 0 auto;
margin-left: 2px;
color: #98a1b0;
font-size: 12px;
transition:
transform 0.18s ease,
color 0.18s ease;
}
.sc-arrow-open {
transform: rotate(90deg);
color: #1677ff;
}
/* 勾选框单独可点:用于精确勾选/取消,不会带开合明细 */
.sc-check {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
cursor: pointer;
}
.sc-index {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 11px;
background: #e8f1ff;
color: #1677ff;
font-size: 12px;
font-weight: 600;
}
.sc-title {
display: block;
flex: 1 1 auto;
min-width: 0;
max-width: 100%;
overflow: hidden;
font-size: 15px;
font-weight: 600;
color: #1f2329;
white-space: nowrap;
text-overflow: ellipsis;
}
/* 标题内的自定义内容(如图标 + 路径):整段按行内文本参与省略号截断 */
.sc-title .anticon {
vertical-align: -0.125em;
}
.sc-card-body {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px 14px;
padding: 10px 12px;
}
@media (min-width: 576px) {
.sc-card-body {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1200px) {
.sc-card-body {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
.sc-field {
display: flex;
align-items: baseline;
gap: 4px;
min-width: 0;
}
.sc-field-label {
flex: 0 0 auto;
max-width: 42%;
font-size: 12px;
color: #8a919f;
}
.sc-field-label::after {
content: ":";
}
.sc-field-value {
flex: 1 1 auto;
min-width: 0;
font-size: 14px;
color: #1f2329;
word-break: break-all;
}
/* 自定义插槽有渲染内容时,隐藏默认文本 */
.sc-slot:not(:empty) + .sc-fallback {
display: none;
}
.sc-empty {
padding: 40px 0;
}
.sc-pagination {
display: flex;
justify-content: flex-end;
padding: 6px 4px;
}
/* 普通页面:分页栏固定在页面 footer 上方(bottom 由内联 style 动态覆盖) */
.sc-pagination-fixed {
position: fixed;
left: 0;
right: 0;
bottom: 64px;
z-index: 998;
padding: 6px 20px;
background: #fff;
border-top: 1px solid #eef0f4;
}
/* 弹窗模式:内容区撑满 modal body,分页贴在 modal footer 上方 */
.sc-modal-body {
display: flex;
flex-direction: column;
overflow: hidden !important;
}
.sc-modal-body > * {
flex: 0 0 auto;
min-height: 0;
}
.sc-modal-body > .sc-result {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.sc-modal-body > .sc-result > .sc-list {
flex: 1 1 auto;
min-height: 0;
height: auto !important;
}
.sc-modal-body > .sc-result > .sc-pagination {
flex: 0 0 auto;
padding: 6px 0;
background: #fff;
border-top: 1px solid #eef0f4;
}
@media (max-width: 575px) {
.sc-pagination {
justify-content: center;
}
.sc-pagination-fixed {
padding: 6px 8px;
}
}
</style>