20260721173401

This commit is contained in:
oneao committed 2026-07-21 17:34:02 +08:00
1 parent fd161dd302
commit bab8e2972f
32 files changed
+1207 -3171

No files matched your search

@@ -1,197 +0,0 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
defineOptions({ inheritAttrs: false });
const props = defineProps({
bordered: { type: Boolean, default: true },
scroll: { type: Object, default: () => ({}) },
scrollX: { type: [Boolean, Number, String], default: "max-content" },
});
const tableAreaRef = ref(null);
const bodyHeight = ref(240);
const mergedScroll = computed(() => ({
...props.scroll,
x: props.scrollX,
y: bodyHeight.value,
}));
let resizeObserver;
let mutationObserver;
let frameId = 0;
const observedElements = new Set();
function getOuterHeight(element) {
if (!element) return 0;
const style = getComputedStyle(element);
const marginTop = Number.parseFloat(style.marginTop) || 0;
const marginBottom = Number.parseFloat(style.marginBottom) || 0;
return element.getBoundingClientRect().height + marginTop + marginBottom;
}
function clipsOverflowY(element) {
const { overflow, overflowY } = getComputedStyle(element);
return [overflow, overflowY].some((value) => /^(auto|clip|hidden|scroll)$/.test(value));
}
function getAvailableHeight(area) {
const areaTop = area.getBoundingClientRect().top;
let boundaryBottom = Number.POSITIVE_INFINITY;
let ancestor = area.parentElement;
while (ancestor && ancestor !== document.documentElement) {
if (clipsOverflowY(ancestor)) {
const rect = ancestor.getBoundingClientRect();
boundaryBottom = Math.min(boundaryBottom, rect.top + ancestor.clientTop + ancestor.clientHeight);
}
ancestor = ancestor.parentElement;
}
if (Number.isFinite(boundaryBottom) && boundaryBottom > areaTop) {
return Math.floor(boundaryBottom - areaTop);
}
return area.clientHeight;
}
function updateBodyHeight() {
const area = tableAreaRef.value;
if (!area?.clientHeight) return;
const headerHeight = getOuterHeight(
area.querySelector(".ant-table-header") || area.querySelector(".ant-table-thead"),
);
const paginationHeight = getOuterHeight(area.querySelector(".ant-table-pagination"));
const titleHeight = getOuterHeight(area.querySelector(".ant-table-title"));
const footerHeight = getOuterHeight(area.querySelector(".ant-table-footer"));
const nextHeight = Math.max(
1,
getAvailableHeight(area) - headerHeight - paginationHeight - titleHeight - footerHeight,
);
if (nextHeight !== bodyHeight.value) bodyHeight.value = nextHeight;
}
function scheduleHeightUpdate() {
cancelAnimationFrame(frameId);
frameId = requestAnimationFrame(updateBodyHeight);
}
function observe(element) {
if (!element || observedElements.has(element)) return;
resizeObserver?.observe(element);
observedElements.add(element);
}
function syncObservedElements() {
const area = tableAreaRef.value;
if (!area) return;
let ancestor = area.parentElement;
while (ancestor && ancestor !== document.documentElement) {
if (ancestor === area.parentElement || clipsOverflowY(ancestor)) observe(ancestor);
ancestor = ancestor.parentElement;
}
observe(area.querySelector(".ant-table-header"));
observe(area.querySelector(".ant-table-pagination"));
observe(area.querySelector(".ant-table-title"));
observe(area.querySelector(".ant-table-footer"));
scheduleHeightUpdate();
}
onMounted(async () => {
resizeObserver = new ResizeObserver(scheduleHeightUpdate);
mutationObserver = new MutationObserver(() => {
nextTick(syncObservedElements);
});
await nextTick();
syncObservedElements();
mutationObserver.observe(tableAreaRef.value, { childList: true, subtree: true });
});
onBeforeUnmount(() => {
cancelAnimationFrame(frameId);
resizeObserver?.disconnect();
mutationObserver?.disconnect();
observedElements.clear();
});
</script>
<template>
<div
ref="tableAreaRef"
class="auto-height-table"
:style="{ '--auto-table-body-height': bodyHeight + 'px' }"
>
<a-table v-bind="$attrs" :bordered="bordered" :scroll="mergedScroll" size="small">
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
</a-table>
</div>
</template>
<style scoped>
.auto-height-table {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
overflow: hidden;
}
:deep(.ant-table-wrapper),
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container),
:deep(.ant-table),
:deep(.ant-table-container) {
min-height: 0;
}
:deep(.ant-table-wrapper) {
display: flex;
height: 100%;
flex: 1 1 auto;
flex-direction: column;
}
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container),
:deep(.ant-table),
:deep(.ant-table-container) {
display: flex;
flex: 1 1 auto;
flex-direction: column;
}
:deep(.ant-table-header),
:deep(.ant-table-pagination) {
flex: 0 0 auto;
}
:deep(.ant-table-body) {
height: var(--auto-table-body-height);
min-height: 0;
max-height: var(--auto-table-body-height) !important;
flex: 0 0 var(--auto-table-body-height);
overflow-y: auto !important;
}
/* scroll.y 模式下 antdv 不会渲染 body 底部外边框;且直接加在滚动容器 .ant-table-body
上时,边框会被内部滚动内容的最后一行盖住、或被祖先 overflow:hidden 裁掉,表现为
“看不到/被遮挡”。所以把底边框挂在不滚动的 .ant-table-container 上,并强制 border-box
让边框计入盒子高度,避免溢出裁切。 */
::deep(.ant-table-container),
::deep(.ant-table-body) {
box-sizing: border-box;
}
::deep(.ant-table-bordered .ant-table-container) {
border-bottom: 1px solid var(--ant-color-border-secondary, #f0f0f0);
}
</style>
@@ -0,0 +1,68 @@
<script setup>
import { computed } from "vue";
import { useLookupOptions } from "./select/useLookupOptions";
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: { type: String, default: "" },
labelKey: { type: String, default: "b_name" },
valueKey: { type: String, default: "b_id" },
viewName: { type: String, required: true },
searchCondition: { type: String, default: "" },
searchColumns: { type: Array, default: () => [] },
orderBy: { type: String, default: "" },
pageSize: { type: Number, default: 20 },
searchDelay: { type: Number, default: 300 },
allowClear: { type: Boolean, default: true },
});
const emit = defineEmits(["update:modelValue", "change"]);
const lookup = useLookupOptions(props);
const options = computed(() =>
lookup.rows.value.map((row) => ({
value: String(row[props.labelKey] ?? ""),
label: String(row[props.labelKey] ?? ""),
key: String(row[props.valueKey] ?? row[props.labelKey] ?? ""),
record: row,
})),
);
function onOpenChange(open) {
if (open) lookup.open();
else lookup.resetAfterClose();
}
function onChange(value) {
emit("update:modelValue", String(value ?? ""));
if (!value) emit("change", null);
}
function onSelect(_value, option) {
emit("change", option.record ?? null);
}
</script>
<template>
<a-auto-complete
v-bind="$attrs"
class="fms-auto-complete"
:value="modelValue"
:options="options"
:show-search="{ onSearch: lookup.search }"
:not-found-content="lookup.loading.value ? '加载中...' : '暂无数据'"
:allow-clear="allowClear"
@change="onChange"
@focus="lookup.open"
@open-change="onOpenChange"
@popup-scroll="lookup.onPopupScroll"
@select="onSelect"
/>
</template>
<style scoped>
.fms-auto-complete {
width: 100%;
}
</style>
@@ -0,0 +1,171 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: { type: String, default: "" },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
allowClear: { type: Boolean, default: true },
maxlength: { type: Number, default: undefined },
popupWidth: { type: [Number, String], default: 420 },
popupHeight: { type: [Number, String], default: 180 },
placement: { type: String, default: "bottomCenter" },
});
const emit = defineEmits(["update:modelValue", "change", "focus", "blur", "openChange"]);
const open = ref(false);
const inputRef = ref(null);
const textareaRef = ref(null);
const editorRef = ref(null);
const triggerWidth = ref(0);
const popoverPlacement = computed(() =>
props.placement === "bottomCenter" ? "bottom" : props.placement,
);
const popupWidth = computed(() =>
typeof props.popupWidth === "number" ? `${props.popupWidth}px` : props.popupWidth,
);
const popupHeight = computed(() =>
typeof props.popupHeight === "number" ? `${props.popupHeight}px` : props.popupHeight,
);
const popupStyles = computed(() => ({
container: {
width: popupWidth.value,
minWidth: triggerWidth.value ? `${triggerWidth.value}px` : undefined,
maxWidth: "calc(100vw - 24px)",
padding: "8px",
},
}));
function updateValue(value) {
emit("update:modelValue", value ?? "");
}
function onInputChange(event) {
emit("change", event);
}
async function showEditor() {
if (props.disabled || open.value) return;
const inputElement = inputRef.value?.$el ?? inputRef.value;
triggerWidth.value = inputElement?.getBoundingClientRect?.().width ?? 0;
open.value = true;
emit("openChange", true);
await nextTick();
textareaRef.value?.focus?.({ cursor: "end" });
}
function onInputFocus(event) {
emit("focus", event);
showEditor();
}
function onOpenChange(value) {
if (value && props.disabled) return;
open.value = value;
emit("openChange", value);
}
function closeEditor() {
if (!open.value) return;
open.value = false;
emit("openChange", false);
}
function onDocumentPointerDown(event) {
if (!open.value) return;
const inputElement = inputRef.value?.$el ?? inputRef.value;
const path = event.composedPath?.() ?? [];
if (path.includes(inputElement) || path.includes(editorRef.value)) return;
closeEditor();
}
function onTextareaKeydown(event) {
event.stopPropagation();
if (event.key === "Escape") {
event.preventDefault();
closeEditor();
}
}
function onInputBlur(event) {
if (!open.value) emit("blur", event);
}
function onTextareaBlur(event) {
emit("blur", event);
}
onMounted(() => document.addEventListener("pointerdown", onDocumentPointerDown, true));
onBeforeUnmount(() => document.removeEventListener("pointerdown", onDocumentPointerDown, true));
</script>
<template>
<a-popover
:open="open"
:trigger="[]"
:placement="popoverPlacement"
:arrow="false"
:styles="popupStyles"
@open-change="onOpenChange"
>
<template #content>
<div ref="editorRef" class="fms-memo-editor" @mousedown.stop @click.stop>
<a-textarea
ref="textareaRef"
:value="modelValue"
:maxlength="maxlength"
:placeholder="$attrs.placeholder"
:disabled="disabled"
:readonly="readonly"
:style="{ height: popupHeight, resize: 'both' }"
@update:value="updateValue"
@change="onInputChange"
@keydown="onTextareaKeydown"
@blur="onTextareaBlur"
/>
</div>
</template>
<a-input
ref="inputRef"
v-bind="$attrs"
class="fms-memo-input"
:value="modelValue"
:disabled="disabled"
:readonly="readonly"
:allow-clear="allowClear"
:maxlength="maxlength"
@update:value="updateValue"
@change="onInputChange"
@focus="onInputFocus"
@blur="onInputBlur"
@click.stop="showEditor"
@keydown.stop
>
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
</a-input>
</a-popover>
</template>
<style scoped>
.fms-memo-input {
width: 100%;
}
.fms-memo-editor {
width: 100%;
}
.fms-memo-editor :deep(textarea) {
min-width: 180px;
min-height: 96px;
}
</style>
@@ -0,0 +1,84 @@
<script setup>
import { computed } from "vue";
import { useLookupOptions } from "./select/useLookupOptions";
defineOptions({ inheritAttrs: false });
const props = defineProps({
modelValue: { type: null, default: "" },
label: { type: String, default: "" },
labelKey: { type: String, default: "b_name" },
valueKey: { type: String, default: "b_id" },
viewName: { type: String, required: true },
searchCondition: { type: String, default: "" },
searchColumns: { type: Array, default: () => [] },
orderBy: { type: String, default: "" },
pageSize: { type: Number, default: 20 },
searchDelay: { type: Number, default: 300 },
allowClear: { type: Boolean, default: true },
});
const emit = defineEmits(["update:modelValue", "update:label", "change"]);
const lookup = useLookupOptions(props);
const options = computed(() => {
const items = lookup.rows.value.map((row) => ({
value: row[props.valueKey],
label: String(row[props.labelKey] ?? ""),
record: row,
}));
if (
props.modelValue !== "" &&
props.modelValue != null &&
props.label &&
!items.some((item) => item.value === props.modelValue)
) {
items.unshift({ value: props.modelValue, label: props.label, record: null });
}
return items;
});
function onOpenChange(open) {
if (open) lookup.open();
else lookup.resetAfterClose();
}
function onSelect(value, option) {
emit("update:modelValue", value);
emit("update:label", String(option.label ?? ""));
emit("change", option.record ?? null);
}
function onChange(value) {
if (value !== undefined && value !== null) return;
emit("update:modelValue", "");
emit("update:label", "");
emit("change", null);
}
</script>
<template>
<a-select
v-bind="$attrs"
class="fms-select"
:value="modelValue === '' ? undefined : modelValue"
:options="options"
:loading="lookup.loading.value"
:filter-option="false"
:allow-clear="allowClear"
show-search
@search="lookup.search"
@open-change="onOpenChange"
@popup-scroll="lookup.onPopupScroll"
@select="onSelect"
@change="onChange"
/>
</template>
<style scoped>
.fms-select {
width: 100%;
}
</style>
@@ -0,0 +1,76 @@
<script setup>
import { computed, nextTick, ref, useSlots } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { StkTable } from "stk-table-vue";
defineOptions({ inheritAttrs: false });
defineProps({
virtual: { type: Boolean, default: true },
virtualX: { type: Boolean, default: true },
colResizable: { type: Boolean, default: true },
bordered: { type: [Boolean, String], default: true },
noDataFull: { type: Boolean, default: true },
});
const slots = useSlots();
const tableRef = ref(null);
const { resume: refreshLayout } = useRafFn(
async () => {
await nextTick();
tableRef.value?.initVirtualScroll?.();
await nextTick();
},
{ immediate: false, once: true },
);
useResizeObserver(
() => tableRef.value?.$el,
([entry]) => {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) refreshLayout();
},
);
// Forward every slot except `empty`; the `empty` slot is handled below so the
// default Result fallback only renders when the parent does not provide one.
const forwardSlots = computed(() => {
const result = {};
for (const name in slots) {
if (name !== "empty") result[name] = slots[name];
}
return result;
});
</script>
<template>
<StkTable
ref="tableRef"
class="fms-table"
v-bind="$attrs"
:auto-resize="false"
:virtual="virtual"
:virtual-x="virtualX"
:col-resizable="colResizable"
:bordered="bordered"
:no-data-full="noDataFull"
>
<template v-for="(slot, name) in forwardSlots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
<template v-if="slots.empty" #empty>
<slot name="empty" />
</template>
<template v-else #empty>
<a-result status="info" title="暂无数据" sub-title="当前没有可显示的内容" />
</template>
</StkTable>
</template>
<style scoped>
.fms-table {
width: 100%;
height: 100%;
}
</style>
@@ -0,0 +1,16 @@
<script setup>
defineOptions({ inheritAttrs: false });
defineProps({
blockNode: { type: Boolean, default: true },
virtual: { type: Boolean, default: true },
});
</script>
<template>
<a-tree v-bind="$attrs" :block-node="blockNode" :virtual="virtual">
<template v-for="(_, name) in $slots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
</template>
</a-tree>
</template>
@@ -0,0 +1,128 @@
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { useTimeoutFn } from "@vueuse/core";
import { pageDataApi } from "@/services/api";
function escapeSearchText(value) {
return String(value).replaceAll("'", "''");
}
function combineConditions(baseCondition, searchCondition) {
if (!baseCondition) return searchCondition;
if (!searchCondition) return baseCondition;
return `(${baseCondition}) AND (${searchCondition})`;
}
export function useLookupOptions(props) {
const rows = ref([]);
const loading = ref(false);
const pageNo = ref(0);
const total = ref(0);
const query = ref("");
const searchColumns = computed(() => [
...new Set([props.labelKey, props.valueKey, ...props.searchColumns].filter(Boolean)),
]);
let requestId = 0;
async function load(searchText = query.value, append = false) {
if (!props.viewName) return;
const currentRequestId = ++requestId;
const requestedPage = append ? pageNo.value + 1 : 1;
const normalizedSearchText = String(searchText ?? "").trim();
const searchFilter = normalizedSearchText
? `${props.labelKey} LIKE '%${escapeSearchText(normalizedSearchText)}%'`
: "";
loading.value = true;
try {
const response = await pageDataApi({
viewName: props.viewName,
orderBy: props.orderBy || props.labelKey,
searchColumns: searchColumns.value,
searchCondition: combineConditions(props.searchCondition, searchFilter),
pageNo: requestedPage,
pageSize: props.pageSize,
});
if (currentRequestId !== requestId) return;
const result = response.data ?? {};
const nextRows = Array.isArray(result.rows) ? result.rows : [];
rows.value = append ? [...rows.value, ...nextRows] : nextRows;
pageNo.value = Number(result.page_no) || requestedPage;
total.value = Number(result.total ?? result.row_count) || 0;
query.value = normalizedSearchText;
} catch {
if (currentRequestId === requestId && !append) {
rows.value = [];
pageNo.value = 0;
total.value = 0;
}
} finally {
if (currentRequestId === requestId) loading.value = false;
}
}
const { start: startSearchTimer, stop: stopSearchTimer } = useTimeoutFn(
() => load(query.value),
() => props.searchDelay,
{ immediate: false },
);
const { start: startResetTimer, stop: stopResetTimer } = useTimeoutFn(reset, 200, {
immediate: false,
});
function search(value) {
stopResetTimer();
query.value = String(value ?? "");
startSearchTimer();
}
function open() {
stopResetTimer();
if (!rows.value.length && !loading.value) load(query.value);
}
function reset() {
stopSearchTimer();
stopResetTimer();
requestId += 1;
rows.value = [];
loading.value = false;
pageNo.value = 0;
total.value = 0;
query.value = "";
}
function resetAfterClose() {
startResetTimer();
}
function loadMore() {
if (loading.value || rows.value.length >= total.value) return;
load(query.value, true);
}
function onPopupScroll(event) {
const target = event.currentTarget || event.target;
if (target.scrollTop + target.clientHeight >= target.scrollHeight - 24) loadMore();
}
watch(
() => [
props.viewName,
props.labelKey,
props.valueKey,
props.orderBy,
props.searchCondition,
props.searchColumns,
],
reset,
);
onBeforeUnmount(reset);
return { rows, loading, query, load, search, open, resetAfterClose, onPopupScroll };
}