20260726225220

This commit is contained in:
oneao committed 2026-07-26 22:52:21 +08:00
1 parent e3c7c6f8da
commit a8478d60ed
170 files changed
+6786 -1678

No files matched your search

+11 -5
View File
@@ -20,6 +20,8 @@ let lastTime = 0;
let totalFrames = 0;
let totalTime = 0;
let minFps = Infinity;
const avgFps = ref(0);
const minFpsDisplay = ref("--");
function tick(now) {
frames++;
@@ -30,6 +32,8 @@ function tick(now) {
currentFps.value = fps;
if (fps < minFps) minFps = fps;
totalTime += elapsed;
avgFps.value = Math.round((totalFrames * 1000) / totalTime);
minFpsDisplay.value = minFps;
const next =
history.value.length >= props.historySize ? history.value.slice(1) : history.value.slice();
next.push(fps);
@@ -49,9 +53,6 @@ onBeforeUnmount(() => {
if (rafId) cancelAnimationFrame(rafId);
});
const avgFps = computed(() => (totalTime > 0 ? Math.round((totalFrames * 1000) / totalTime) : 0));
const minFpsDisplay = computed(() => (minFps === Infinity ? "--" : minFps));
const fpsColor = computed(() => {
const f = currentFps.value;
if (f >= 50) return "#52c41a";
@@ -70,7 +71,11 @@ function reset() {
totalFrames = 0;
totalTime = 0;
minFps = Infinity;
frames = 0;
lastTime = performance.now();
currentFps.value = 0;
avgFps.value = 0;
minFpsDisplay.value = "--";
}
</script>
@@ -104,7 +109,7 @@ function reset() {
display: flex;
flex-direction: column;
gap: 6px;
width: 108px;
width: 132px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.4;
@@ -160,11 +165,12 @@ function reset() {
align-items: flex-end;
gap: 1px;
height: 32px;
overflow: hidden;
}
.fps-monitor__bar {
flex: 1;
min-width: 2px;
min-width: 0;
border-radius: 1px;
transition:
height 0.2s ease,
@@ -0,0 +1,207 @@
<script setup>
import { computed, h, nextTick, ref, toRef, useSlots, watch } from "vue";
import { message } from "antdv-next";
import { Columns3, RefreshCw } from "@lucide/vue";
import FmsModuleTable from "./FmsModuleTable.vue";
import FmsQueryToolbar from "./FmsQueryToolbar.vue";
import { useModuleList } from "./useModuleList";
const props = defineProps({
moduleCode: { type: String, default: "" },
columnExtensions: { type: Object, default: () => ({}) },
rowActions: { type: Array, default: () => [] },
rowClassName: { type: Function, default: undefined },
expandable: { type: [Boolean, Object], default: false },
pageSize: { type: Number, default: 20 },
inlineActionLimit: { type: Number, default: 2 },
});
const emit = defineEmits(["row-click", "cell-click", "row-action", "expand", "loaded"]);
const slots = useSlots();
const tableRef = ref(null);
const moduleList = useModuleList({
moduleCode: toRef(props, "moduleCode"),
pageSize: props.pageSize,
});
const moreMenuItems = [
{ key: "refresh", label: "刷新数据", icon: h(RefreshCw, { size: 14 }) },
{ key: "reset-columns", label: "恢复配置列宽", icon: h(Columns3, { size: 14 }) },
];
const cellSlotNames = computed(() => Object.keys(slots).filter((name) => name.startsWith("cell-")));
function reload() {
return moduleList.reload();
}
async function resetColumns() {
await nextTick();
tableRef.value?.resetColumns?.();
}
async function handleMoreAction({ key }) {
if (key === "refresh") {
await reload();
return;
}
if (key === "reset-columns") {
await resetColumns();
message.success("已恢复配置列宽");
}
}
watch(
() => moduleList.dataSource.value,
(rows) => {
emit("loaded", {
rows,
total: moduleList.pagination.total,
module: moduleList.dataModule.value,
});
},
);
defineExpose({
reload,
resetColumns,
initialize: moduleList.initialize,
dataSource: moduleList.dataSource,
dataModule: moduleList.dataModule,
pagination: moduleList.pagination,
});
</script>
<template>
<div class="fms-module-list-page">
<div v-if="moduleList.configError.value" class="module-config-error">
<a-result status="error" title="模块配置加载失败" :sub-title="moduleList.configError.value">
<template #extra>
<a-button type="primary" @click="moduleList.initialize">重新加载</a-button>
</template>
</a-result>
</div>
<template v-else>
<FmsQueryToolbar
:query-items="moduleList.queryItems.value"
:loading="moduleList.dataLoading.value"
:more-items="moreMenuItems"
@search="moduleList.search"
@reset="moduleList.resetSearch"
@more-action="handleMoreAction"
>
<template #actions>
<slot
name="actions"
:reload="reload"
:data-module="moduleList.dataModule.value"
:loading="moduleList.loading.value"
/>
</template>
</FmsQueryToolbar>
<section class="module-table-panel">
<div class="module-table-stage">
<FmsModuleTable
ref="tableRef"
:fields="moduleList.activeFields.value"
:list-config="moduleList.listConfig.value"
:data-source="moduleList.dataSource.value"
:row-key="moduleList.resolveRowKey"
:column-extensions="columnExtensions"
:row-actions="rowActions"
:row-class-name="rowClassName"
:expandable="expandable"
:inline-action-limit="inlineActionLimit"
@row-click="$emit('row-click', $event)"
@cell-click="$emit('cell-click', $event)"
@row-action="$emit('row-action', $event)"
@expand="$emit('expand', $event)"
>
<template v-for="slotName in cellSlotNames" :key="slotName" #[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
<template v-if="$slots['expanded-row']" #expanded-row="slotProps">
<slot name="expanded-row" v-bind="slotProps" />
</template>
<template v-if="$slots.empty" #empty>
<slot name="empty" />
</template>
</FmsModuleTable>
<div v-if="moduleList.loading.value" class="module-table-loading">
<a-spin />
</div>
</div>
<div v-if="moduleList.dataError.value" class="module-data-error">
{{ moduleList.dataError.value }}
</div>
<a-pagination
:current="moduleList.pagination.current"
:page-size="moduleList.pagination.pageSize"
:total="moduleList.pagination.total"
:show-total="(total) => `共 ${total} 条`"
show-size-changer
@change="moduleList.changePage"
/>
</section>
</template>
</div>
</template>
<style scoped lang="scss">
.fms-module-list-page {
display: flex;
width: 100%;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--fms-border, #e5e7eb);
border-radius: 6px;
background: var(--fms-surface, #fff);
}
.module-config-error {
display: grid;
flex: 1;
place-items: center;
}
.module-table-panel {
display: flex;
flex: 1;
min-height: 0;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
> :deep(.ant-pagination) {
flex: none;
align-self: flex-end;
}
}
.module-table-stage {
position: relative;
flex: 1;
min-height: 220px;
}
.module-table-loading {
position: absolute;
z-index: 5;
inset: 0;
display: grid;
place-items: center;
background: rgb(255 255 255 / 68%);
}
.module-data-error {
flex: none;
color: #ff4d4f;
font-size: 13px;
}
</style>
@@ -0,0 +1,320 @@
<script setup>
import { computed, h, ref, shallowRef, useSlots, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
const props = defineProps({
fields: { type: Array, default: () => [] },
listConfig: { type: Array, default: () => [] },
dataSource: { type: Array, default: () => [] },
rowKey: { type: [String, Function], default: "b_id" },
columnExtensions: { type: Object, default: () => ({}) },
rowActions: { type: Array, default: () => [] },
rowClassName: { type: Function, default: undefined },
expandable: { type: [Boolean, Object], default: false },
inlineActionLimit: { type: Number, default: 2 },
});
const emit = defineEmits(["row-click", "cell-click", "row-action", "expand"]);
const slots = useSlots();
const tableRef = ref(null);
const columns = shallowRef([]);
let expandedRecord = null;
const expansionEnabled = computed(
() =>
props.expandable === true ||
Boolean(props.expandable?.enabled) ||
Boolean(slots["expanded-row"]),
);
const expansionOptions = computed(() =>
props.expandable && typeof props.expandable === "object" ? props.expandable : {},
);
const expandConfig = computed(() => ({ height: Number(expansionOptions.value.height) || 180 }));
function parseOptions(field) {
if (!field?.b_options) return { config: {}, options: [] };
try {
const parsed = JSON.parse(field.b_options);
const config = Array.isArray(parsed) ? {} : parsed || {};
const source = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.options)
? parsed.options
: Array.isArray(parsed?.items)
? parsed.items
: [];
const options = source.map((option) => {
if (option && typeof option === "object") {
const value = option.value ?? option.key ?? option.id;
return { ...option, value, label: String(option.label ?? option.name ?? value ?? "") };
}
return { value: option, label: String(option ?? "") };
});
return { config, options };
} catch {
return { config: {}, options: [] };
}
}
function formatDateValue(value, withTime) {
if (value === null || value === undefined || value === "") return "-";
const text = String(value).replace("T", " ");
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return withTime ? text.slice(0, 19) : text.slice(0, 10);
const date = new Date(value);
if (Number.isNaN(date.getTime())) return text;
const pad = (number) => String(number).padStart(2, "0");
const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return withTime
? `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
: datePart;
}
function formatCellValue(value, field, optionInfo) {
if (value === null || value === undefined || value === "") return "-";
if (field.b_type === "date") return formatDateValue(value, false);
if (field.b_type === "datetime") return formatDateValue(value, true);
if (field.b_type === "switch") {
return value === true || Number(value) === 1
? (optionInfo.config.trueLabel ?? "是")
: (optionInfo.config.falseLabel ?? "否");
}
if (field.b_type === "select") {
const option = optionInfo.options.find((item) => String(item.value) === String(value));
return option?.label ?? String(value);
}
if (field.b_type === "money") {
const number = Number(value);
if (!Number.isFinite(number)) return String(value);
const precision = Number.isInteger(optionInfo.config.precision)
? optionInfo.config.precision
: 2;
return number.toLocaleString("zh-CN", {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
}
return String(value);
}
function resolveExtension(fieldName) {
return props.columnExtensions?.[fieldName] || {};
}
function createRuntimeColumn(config, field) {
const optionInfo = parseOptions(field);
const extension = resolveExtension(field.b_field);
const { formatter, clickable, cellClassName, onClick: _onClick, ...columnProps } = extension;
const slot = slots[`cell-${field.b_field}`];
const column = {
title: field.b_name || field.b_field,
dataIndex: field.b_field,
key: field.b_field,
width: Number(config.b_width) || 120,
...columnProps,
};
column.customCell = ({ row, cellValue, rowIndex, colIndex }) => {
const formattedValue = formatter
? formatter({ value: cellValue, record: row, field, config })
: formatCellValue(cellValue, field, optionInfo);
const cellClass =
typeof cellClassName === "function"
? cellClassName({ value: cellValue, record: row, field, config })
: cellClassName;
const content = slot
? slot({
value: cellValue,
displayValue: formattedValue,
record: row,
field,
config,
rowIndex,
colIndex,
})
: formattedValue;
return h(
"span",
{
class: [
"fms-module-cell",
cellClass,
{ "fms-module-cell--clickable": clickable || extension.onClick },
],
title: typeof formattedValue === "string" && formattedValue !== "-" ? formattedValue : "",
},
content,
);
};
return column;
}
function createActionColumn() {
if (!props.rowActions.length) return null;
const actions = props.rowActions.map((action, index) => {
const actionContext = (record) => ({ ...record, record, action });
return {
...action,
key: String(action.key ?? action.type ?? index),
type: action.type || (action.danger ? "danger" : "action"),
visible:
typeof action.visible === "function"
? (record) => action.visible(actionContext(record))
: action.visible,
disabled:
typeof action.disabled === "function"
? (record) => action.disabled(actionContext(record))
: action.disabled,
onClick: (record) => {
action.onClick?.(record);
emit("row-action", {
key: String(action.key ?? action.type ?? index),
record,
action,
});
},
};
});
const inlineLimit = Math.max(0, props.inlineActionLimit);
const inlineActions = actions.slice(0, inlineLimit);
const overflowActions = actions.slice(inlineLimit);
if (overflowActions.length) {
inlineActions.push({ type: "more", title: "更多操作", items: overflowActions });
}
return {
dataIndex: "__actions__",
key: "__actions__",
title: "操作",
width: Math.max(70, inlineActions.length * 52),
align: "center",
fixed: "right",
resizable: false,
actions: inlineActions,
};
}
function buildColumns() {
const fieldById = new Map(props.fields.map((field) => [String(field.b_id), field]));
const runtimeColumns = props.listConfig
.filter((config) => config.b_visible === 1)
.map((config) => {
const field = fieldById.get(String(config.b_field_id));
return field ? createRuntimeColumn(config, field) : null;
})
.filter(Boolean);
if (expansionEnabled.value) {
runtimeColumns.unshift({
type: "expand",
dataIndex: "__expand__",
key: "__expand__",
title: "",
width: 42,
fixed: "left",
resizable: false,
});
}
const actionColumn = createActionColumn();
if (actionColumn) runtimeColumns.push(actionColumn);
return runtimeColumns;
}
function rebuildColumns() {
columns.value = buildColumns();
}
function resetColumns() {
rebuildColumns();
tableRef.value?.clearSelectedArea?.();
tableRef.value?.initVirtualScroll?.();
}
function handleCellClick(event, record, column, meta) {
if (!column?.dataIndex || String(column.dataIndex).startsWith("__")) return;
const field = props.fields.find((item) => item.b_field === column.dataIndex);
const payload = {
event,
record,
field,
column,
value: record?.[column.dataIndex],
...meta,
};
resolveExtension(column.dataIndex).onClick?.(payload);
emit("cell-click", payload);
}
function handleRowClick(event, record, meta) {
emit("row-click", { event, record, ...meta });
}
function handleToggleExpand(payload) {
const single = expansionOptions.value.mode !== "multiple";
if (payload.expanded && single && expandedRecord && expandedRecord !== payload.row) {
tableRef.value?.setRowExpand?.(expandedRecord, false, { silent: true });
}
expandedRecord = payload.expanded
? payload.row
: expandedRecord === payload.row
? null
: expandedRecord;
emit("expand", { expanded: payload.expanded, record: payload.row, column: payload.col });
}
watch(
[
() => props.fields,
() => props.listConfig,
() => props.columnExtensions,
() => props.rowActions,
expansionEnabled,
],
rebuildColumns,
{ immediate: true, deep: true },
);
watch(
() => props.dataSource,
() => {
expandedRecord = null;
},
);
defineExpose({ resetColumns, clearSelectedArea: () => tableRef.value?.clearSelectedArea?.() });
</script>
<template>
<FmsTable
ref="tableRef"
v-model:columns="columns"
:data-source="dataSource"
:row-key="rowKey"
:row-class-name="rowClassName"
:expand-config="expansionEnabled ? expandConfig : undefined"
:editable="false"
@cell-click="handleCellClick"
@row-click="handleRowClick"
@toggle-row-expand="handleToggleExpand"
>
<template v-if="expansionEnabled" #expand="{ row, col }">
<slot name="expanded-row" :record="row" :column="col" />
</template>
<template v-if="$slots.empty" #empty>
<slot name="empty" />
</template>
</FmsTable>
</template>
<style scoped lang="scss">
:deep(.fms-module-cell) {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:deep(.fms-module-cell--clickable) {
color: #1677ff;
cursor: pointer;
}
</style>
@@ -0,0 +1,174 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
item: { type: Object, required: true },
modelValue: { type: [String, Number, Boolean, Array, Object], default: undefined },
compact: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue", "enter"]);
const isRange = computed(
() =>
props.item.config.b_operator === "between" ||
props.item.config.b_component === "number-range" ||
props.item.field.b_type === "datetime",
);
const isNumeric = computed(
() => props.item.field.b_type === "number" || props.item.field.b_type === "money",
);
const isDate = computed(
() =>
props.item.config.b_component === "date-picker" ||
props.item.field.b_type === "date" ||
props.item.field.b_type === "datetime",
);
const value = computed({
get: () => props.modelValue,
set: (nextValue) => emit("update:modelValue", nextValue),
});
const startValue = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue[0] : undefined),
set: (nextValue) => {
const range = Array.isArray(props.modelValue) ? [...props.modelValue] : [];
range[0] = nextValue;
emit("update:modelValue", range);
},
});
const endValue = computed({
get: () => (Array.isArray(props.modelValue) ? props.modelValue[1] : undefined),
set: (nextValue) => {
const range = Array.isArray(props.modelValue) ? [...props.modelValue] : [];
range[1] = nextValue;
emit("update:modelValue", range);
},
});
function isEmptyValue(current) {
return current === undefined || current === null || current === "";
}
function comparableDate(current) {
if (isEmptyValue(current)) return null;
if (current && typeof current.format === "function") {
const dateNumber = Number(current.format("YYYYMMDD"));
return Number.isFinite(dateNumber) ? dateNumber : null;
}
const match = String(current).match(/^(\d{4})-(\d{2})-(\d{2})/);
if (match) return Number(`${match[1]}${match[2]}${match[3]}`);
const date = new Date(current);
if (Number.isNaN(date.getTime())) return null;
return date.getFullYear() * 10000 + (date.getMonth() + 1) * 100 + date.getDate();
}
function isDateAfterLimit(current) {
const currentDate = comparableDate(current);
const limitDate = comparableDate(endValue.value);
return currentDate !== null && limitDate !== null && currentDate > limitDate;
}
function isDateBeforeLimit(current) {
const currentDate = comparableDate(current);
const limitDate = comparableDate(startValue.value);
return currentDate !== null && limitDate !== null && currentDate < limitDate;
}
</script>
<template>
<div
v-if="isRange"
class="fms-query-control__range"
:class="{ 'fms-query-control__range--compact': compact }"
>
<template v-if="isDate">
<a-date-picker
v-model:value="startValue"
:disabled-date="isDateAfterLimit"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
aria-label="开始日期"
/>
<span class="fms-query-control__separator">-</span>
<a-date-picker
v-model:value="endValue"
:disabled-date="isDateBeforeLimit"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
aria-label="结束日期"
/>
</template>
<template v-else>
<a-input-number v-if="isNumeric" v-model:value="startValue" @keyup.enter="$emit('enter')" />
<a-input v-else v-model:value="startValue" @press-enter="$emit('enter')" />
<span class="fms-query-control__separator">-</span>
<a-input-number v-if="isNumeric" v-model:value="endValue" @keyup.enter="$emit('enter')" />
<a-input v-else v-model:value="endValue" @press-enter="$emit('enter')" />
</template>
</div>
<a-select
v-else-if="item.config.b_component === 'select'"
v-model:value="value"
:mode="item.config.b_operator === 'in' ? 'multiple' : undefined"
:options="item.options"
:max-tag-count="compact ? 1 : undefined"
allow-clear
/>
<a-date-picker
v-else-if="isDate"
v-model:value="value"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
<a-input-number v-else-if="isNumeric" v-model:value="value" @keyup.enter="$emit('enter')" />
<a-input v-else v-model:value="value" allow-clear @press-enter="$emit('enter')" />
</template>
<style scoped lang="scss">
.fms-query-control__range {
display: grid;
width: 100%;
min-width: 0;
align-items: center;
grid-template-columns: minmax(0, 1fr) 18px minmax(0, 1fr);
gap: 6px;
> :deep(.ant-input-number),
> :deep(.ant-picker) {
width: 100%;
}
}
.fms-query-control__separator {
color: var(--fms-text-secondary, #646a73);
font-size: 12px;
text-align: center;
}
.fms-query-control__range--compact {
grid-template-columns: minmax(0, 1fr) 24px minmax(0, 1fr);
gap: 0;
> :deep(.ant-input),
> :deep(.ant-input-number),
> :deep(.ant-picker) {
border-radius: 0;
}
> :deep(.ant-input-number),
> :deep(.ant-picker),
> :deep(.ant-input) {
margin-left: -1px;
}
.fms-query-control__separator {
display: inline-flex;
height: 32px;
align-items: center;
justify-content: center;
border-block: 1px solid var(--fms-border, #d9d9d9);
background: var(--fms-surface, #fff);
}
}
</style>
@@ -0,0 +1,403 @@
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { ChevronDown, ChevronUp, Ellipsis, RotateCcw, Search } from "@lucide/vue";
import FmsQueryControl from "./FmsQueryControl.vue";
const props = defineProps({
queryItems: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
moreItems: { type: Array, default: () => [] },
defaultAdvancedCount: { type: Number, default: 6 },
});
const emit = defineEmits(["search", "reset", "more-action"]);
const filters = reactive({});
const quickFieldName = ref("");
const quickValue = ref(undefined);
const advancedOpen = ref(false);
const advancedMoreOpen = ref(false);
const quickFieldOptions = computed(() =>
props.queryItems.map((item) => ({
key: item.field.b_field,
value: item.field.b_field,
label: item.field.b_name || item.field.b_field,
})),
);
const quickItem = computed(() =>
props.queryItems.find((item) => item.field.b_field === quickFieldName.value),
);
const hiddenAdvancedQueryCount = computed(() =>
Math.max(props.queryItems.length - props.defaultAdvancedCount, 0),
);
const visibleAdvancedQueryItems = computed(() =>
advancedMoreOpen.value ? props.queryItems : props.queryItems.slice(0, props.defaultAdvancedCount),
);
function isRangeQuery(item) {
return (
item.config.b_operator === "between" ||
item.config.b_component === "number-range" ||
item.field.b_type === "datetime"
);
}
function isNumericField(item) {
return item.field.b_type === "number" || item.field.b_type === "money";
}
function isDateQuery(item) {
return (
item.config.b_component === "date-picker" ||
item.field.b_type === "date" ||
item.field.b_type === "datetime"
);
}
function emptyQueryValue(item) {
if (isRangeQuery(item) || item.config.b_operator === "in") return [];
return undefined;
}
function defaultQueryValue(item) {
const configuredValue = item.config.b_default_value;
if (configuredValue === null || configuredValue === undefined || configuredValue === "") {
return emptyQueryValue(item);
}
if (isRangeQuery(item) || item.config.b_operator === "in") {
try {
const parsed = JSON.parse(configuredValue);
if (Array.isArray(parsed)) return parsed;
} catch {
// 非 JSON 默认值继续按逗号分隔处理。
}
return String(configuredValue)
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
if (isNumericField(item)) {
const number = Number(configuredValue);
return Number.isFinite(number) ? number : undefined;
}
return configuredValue;
}
function cloneQueryValue(current) {
return Array.isArray(current) ? [...current] : current;
}
function initializeDrafts() {
Object.keys(filters).forEach((key) => delete filters[key]);
props.queryItems.forEach((item) => {
filters[item.field.b_field] = defaultQueryValue(item);
});
const firstItem = props.queryItems[0];
quickFieldName.value = firstItem?.field.b_field || "";
quickValue.value = firstItem ? defaultQueryValue(firstItem) : undefined;
advancedOpen.value = false;
advancedMoreOpen.value = false;
}
function handleQuickFieldChange(fieldName) {
const item = props.queryItems.find((query) => query.field.b_field === fieldName);
quickValue.value = item ? emptyQueryValue(item) : undefined;
}
function handleQuickSearch() {
emit("search", {
mode: "quick",
entries: quickItem.value
? [{ item: quickItem.value, value: cloneQueryValue(quickValue.value) }]
: [],
});
}
function toggleAdvancedSearch() {
const nextOpen = !advancedOpen.value;
if (nextOpen && quickItem.value) {
filters[quickItem.value.field.b_field] = cloneQueryValue(quickValue.value);
}
if (!nextOpen) advancedMoreOpen.value = false;
advancedOpen.value = nextOpen;
}
function handleAdvancedSearch() {
emit("search", {
mode: "advanced",
entries: props.queryItems.map((item) => ({
item,
value: cloneQueryValue(filters[item.field.b_field]),
})),
});
}
function handleAdvancedReset() {
props.queryItems.forEach((item) => {
filters[item.field.b_field] = emptyQueryValue(item);
});
emit("reset");
}
watch(() => props.queryItems, initializeDrafts, { immediate: true });
</script>
<template>
<div class="fms-query-toolbar">
<div class="list-toolbar">
<div class="toolbar-operations">
<slot name="actions" />
</div>
<div class="toolbar-tools">
<a-space-compact
v-if="quickItem && !advancedOpen"
class="quick-search"
:class="{
'quick-search--date-range': isRangeQuery(quickItem) && isDateQuery(quickItem),
}"
>
<a-select
v-model:value="quickFieldName"
class="quick-field-select"
:options="quickFieldOptions"
:popup-match-select-width="false"
@change="handleQuickFieldChange"
/>
<FmsQueryControl
v-model="quickValue"
:item="quickItem"
compact
class="quick-query-control"
@enter="handleQuickSearch"
/>
<a-button
type="primary"
class="quick-submit"
:loading="loading"
aria-label="搜索"
@click="handleQuickSearch"
>
<Search :size="16" />
</a-button>
</a-space-compact>
<a-tooltip v-if="queryItems.length" :title="advancedOpen ? '收起高级搜索' : '高级搜索'">
<a-button
shape="circle"
class="toolbar-icon-button"
:class="{ 'toolbar-icon-button--active': advancedOpen }"
:aria-label="advancedOpen ? '收起高级搜索' : '高级搜索'"
@click="toggleAdvancedSearch"
>
<Search :size="16" />
</a-button>
</a-tooltip>
<a-dropdown
v-if="moreItems.length"
:menu="{ items: moreItems, onClick: (event) => $emit('more-action', event) }"
:trigger="['click']"
placement="bottomRight"
>
<span class="more-trigger">
<a-tooltip title="更多">
<a-button shape="circle" class="toolbar-icon-button" aria-label="更多">
<Ellipsis :size="17" />
</a-button>
</a-tooltip>
</span>
</a-dropdown>
</div>
</div>
<a-form v-if="advancedOpen && queryItems.length" class="query-panel" layout="vertical">
<div class="query-content">
<div class="query-fields">
<a-form-item
v-for="item in visibleAdvancedQueryItems"
:key="item.field.b_field"
:class="{ 'query-field--wide': isRangeQuery(item) }"
:label="item.field.b_name"
>
<FmsQueryControl
v-model="filters[item.field.b_field]"
:item="item"
class="query-control"
@enter="handleAdvancedSearch"
/>
</a-form-item>
</div>
<a-button
v-if="hiddenAdvancedQueryCount"
type="text"
class="query-more-toggle"
@click="advancedMoreOpen = !advancedMoreOpen"
>
<ChevronUp v-if="advancedMoreOpen" :size="15" />
<ChevronDown v-else :size="15" />
{{ advancedMoreOpen ? "收起条件" : `更多条件 (${hiddenAdvancedQueryCount})` }}
</a-button>
</div>
<div class="query-actions">
<a-button type="primary" :loading="loading" @click="handleAdvancedSearch">
<Search :size="15" />
查询
</a-button>
<a-button :disabled="loading" @click="handleAdvancedReset">
<RotateCcw :size="15" />
重置
</a-button>
</div>
</a-form>
</div>
</template>
<style scoped lang="scss">
.fms-query-toolbar {
width: 100%;
min-width: 0;
flex: none;
}
.list-toolbar {
display: flex;
min-height: 50px;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid var(--fms-border, #e5e7eb);
background: var(--fms-surface, #fff);
}
.toolbar-operations {
min-width: 0;
flex: 1;
}
.toolbar-tools {
display: flex;
min-width: 0;
flex: none;
align-items: center;
gap: 8px;
}
.quick-search {
width: min(330px, 38vw);
min-width: 300px;
}
.quick-search--date-range {
width: 470px;
min-width: 470px;
}
.quick-field-select {
width: 132px;
flex: none;
}
.quick-query-control {
width: 0;
min-width: 80px;
flex: 1;
}
.quick-submit {
width: 38px;
flex: none;
padding-inline: 0;
}
.toolbar-icon-button {
display: inline-flex;
width: 34px;
min-width: 34px;
height: 34px;
align-items: center;
justify-content: center;
padding: 0;
&--active {
border-color: #91caff;
background: #e6f4ff;
color: #1677ff;
}
}
.more-trigger {
display: inline-flex;
width: 34px;
height: 34px;
}
.query-panel {
display: grid;
width: min(1100px, calc(100% - 24px));
align-self: flex-start;
grid-template-columns: minmax(0, 1fr) 160px;
gap: 14px;
padding: 10px 12px 12px;
border-left: 1px solid var(--fms-border, #e5e7eb);
border-bottom: 1px solid var(--fms-border, #e5e7eb);
background: var(--fms-surface, #fff);
:deep(.ant-form-item-label) {
padding-bottom: 2px;
}
:deep(.ant-form-item-control) {
min-width: 0;
}
}
.query-content {
display: flex;
min-height: 0;
flex-direction: column;
}
.query-fields {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px 12px;
> :deep(.ant-form-item) {
min-width: 0;
margin: 0;
}
> :deep(.query-field--wide) {
grid-column: span 2;
}
}
.query-more-toggle {
align-self: flex-start;
margin-top: 6px;
padding-inline: 0;
color: var(--fms-text-secondary, #646a73);
}
.query-control {
width: 100%;
}
.query-actions {
display: flex;
width: 160px;
align-self: center;
justify-content: space-between;
gap: 8px;
> :deep(.ant-btn) {
width: 76px;
}
}
</style>
@@ -0,0 +1,461 @@
import { computed, onBeforeUnmount, reactive, ref, shallowRef, unref, watch } from "vue";
import { useRoute } from "vue-router";
import { loadDataApi, pageDataApi } from "@/services/api";
const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function escapeSqlText(value) {
return String(value).replaceAll("'", "''");
}
function requireModuleId(value) {
const id = String(value ?? "");
if (!/^\d+$/.test(id)) throw new Error("模块 ID 格式不正确");
return id;
}
function parseOptions(field) {
if (!field?.b_options) return { optionConfig: {}, options: [] };
try {
const parsed = JSON.parse(field.b_options);
const optionConfig = Array.isArray(parsed) ? {} : parsed || {};
const source = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.options)
? parsed.options
: Array.isArray(parsed?.items)
? parsed.items
: [];
const options = source.map((option) => {
if (option && typeof option === "object") {
const value = option.value ?? option.key ?? option.id;
return { ...option, value, label: String(option.label ?? option.name ?? value ?? "") };
}
return { value: option, label: String(option ?? "") };
});
return { optionConfig, options };
} catch {
return { optionConfig: {}, options: [] };
}
}
function isRangeQuery(item) {
return (
item.config.b_operator === "between" ||
item.config.b_component === "number-range" ||
item.field.b_type === "datetime"
);
}
function isNumericField(item) {
return item.field.b_type === "number" || item.field.b_type === "money";
}
function emptyQueryValue(item) {
if (isRangeQuery(item) || item.config.b_operator === "in") return [];
return undefined;
}
function defaultQueryValue(item) {
const value = item.config.b_default_value;
if (value === null || value === undefined || value === "") return emptyQueryValue(item);
if (isRangeQuery(item) || item.config.b_operator === "in") {
try {
const parsed = JSON.parse(value);
if (Array.isArray(parsed)) return parsed;
} catch {
// 非 JSON 默认值继续按逗号分隔处理。
}
return String(value)
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
if (isNumericField(item)) {
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
return value;
}
function isEmptyValue(value) {
return value === undefined || value === null || value === "";
}
function formatDateValue(value, withTime = false) {
if (isEmptyValue(value)) return "";
if (value && typeof value.format === "function") {
return value.format(withTime ? "YYYY-MM-DD HH:mm:ss" : "YYYY-MM-DD");
}
const text = String(value).replace("T", " ");
if (/^\d{4}-\d{2}-\d{2}/.test(text)) return withTime ? text.slice(0, 19) : text.slice(0, 10);
const date = new Date(value);
if (Number.isNaN(date.getTime())) return text;
const pad = (number) => String(number).padStart(2, "0");
const datePart = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
return withTime
? `${datePart} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
: datePart;
}
function formatSqlDate(value, fieldType, endOfRange = false) {
if (value && typeof value.format === "function") {
if (fieldType === "datetime") {
return `${value.format("YYYY-MM-DD")} ${endOfRange ? "23:59:59" : "00:00:00"}`;
}
return value.format("YYYY-MM-DD");
}
const date = String(value).slice(0, 10);
if (fieldType === "datetime" && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
return `${date} ${endOfRange ? "23:59:59" : "00:00:00"}`;
}
return date;
}
function sqlLiteral(value, field, endOfRange = false) {
if (isEmptyValue(value)) return null;
if (field.b_type === "number" || field.b_type === "money") {
const number = Number(value);
return Number.isFinite(number) ? String(number) : null;
}
if (field.b_type === "switch") return value === true || Number(value) === 1 ? "1" : "0";
const normalized =
field.b_type === "date" || field.b_type === "datetime"
? formatSqlDate(value, field.b_type, endOfRange)
: String(value);
return `N'${escapeSqlText(normalized)}'`;
}
function buildQueryCondition(item, value) {
const fieldName = item.field.b_field;
if (!IDENTIFIER_RE.test(fieldName)) return "";
const identifier = `[${fieldName}]`;
const operator = item.config.b_operator;
if (isRangeQuery(item)) {
const [start, end] = Array.isArray(value) ? value : [];
const startLiteral = sqlLiteral(start, item.field);
const endLiteral = sqlLiteral(end, item.field, true);
if (startLiteral && endLiteral) {
return `${identifier} BETWEEN ${startLiteral} AND ${endLiteral}`;
}
if (startLiteral) return `${identifier} >= ${startLiteral}`;
if (endLiteral) return `${identifier} <= ${endLiteral}`;
return "";
}
if (operator === "in") {
const values = Array.isArray(value)
? value
: String(value ?? "")
.split(",")
.map((part) => part.trim())
.filter(Boolean);
const literals = values.map((entry) => sqlLiteral(entry, item.field)).filter(Boolean);
return literals.length ? `${identifier} IN (${literals.join(", ")})` : "";
}
const literal = sqlLiteral(value, item.field);
if (!literal) return "";
if (operator === "like") return `${identifier} LIKE N'%${escapeSqlText(String(value))}%'`;
return `${identifier} = ${literal}`;
}
function displayQueryValue(item, value) {
if (isEmptyValue(value)) return "";
if (item.field.b_type === "date" || item.field.b_type === "datetime") {
return formatDateValue(value);
}
const option = item.options.find((entry) => String(entry.value) === String(value));
return option?.label ?? String(value);
}
function formatAppliedFilterValue(item, value) {
if (isRangeQuery(item)) {
const [start, end] = Array.isArray(value) ? value : [];
const startText = displayQueryValue(item, start);
const endText = displayQueryValue(item, end);
if (startText && endText) return `${startText} - ${endText}`;
if (startText) return `${startText} 起`;
if (endText) return `${endText} 止`;
return "";
}
if (Array.isArray(value)) {
return value
.map((entry) => displayQueryValue(item, entry))
.filter(Boolean)
.join("、");
}
return displayQueryValue(item, value);
}
function createAppliedFilter(item, value) {
const condition = buildQueryCondition(item, value);
if (!condition) return null;
return {
key: item.field.b_field,
label: item.field.b_name || item.field.b_field,
value: formatAppliedFilterValue(item, value),
condition,
};
}
function resolveKeyField(module, activeFields) {
if (IDENTIFIER_RE.test(module?.b_keyfield || "")) return module.b_keyfield;
return (
activeFields.find((field) => field.b_field === "b_id")?.b_field ||
activeFields.find((field) => IDENTIFIER_RE.test(field.b_field || ""))?.b_field ||
""
);
}
export function useModuleList(options = {}) {
const route = useRoute();
const pageModule = ref(null);
const dataModule = ref(null);
const activeFields = shallowRef([]);
const listConfig = shallowRef([]);
const queryItems = shallowRef([]);
const dataSource = shallowRef([]);
const appliedFilters = shallowRef([]);
const configLoading = ref(false);
const dataLoading = ref(false);
const configError = ref("");
const dataError = ref("");
const pagination = reactive({
current: 1,
pageSize: Number(unref(options.pageSize)) || 20,
total: 0,
});
let requestVersion = 0;
let dataRequestVersion = 0;
let disposed = false;
const loading = computed(() => configLoading.value || dataLoading.value);
const appliedSearchCondition = computed(() =>
appliedFilters.value.map((item) => item.condition).join(" AND "),
);
function commitAppliedFilters(filters) {
appliedFilters.value = filters.filter(Boolean);
}
async function resolveConfiguredModule() {
const moduleCode = String(unref(options.moduleCode) || "").trim();
if (moduleCode) {
const response = await loadDataApi(
"s_module",
`b_code = N'${escapeSqlText(moduleCode)}' AND b_canuse = 1`,
"b_id ASC",
);
const module = response.data?.[0];
if (!module) throw new Error(`未找到编码为 ${moduleCode} 的启用模块`);
return module;
}
const routePath = escapeSqlText(route.path);
const response = await loadDataApi(
"s_module",
`b_route = N'${routePath}' AND b_canuse = 1`,
"b_id ASC",
);
const module = response.data?.[0];
if (!module) throw new Error(`未找到路由 ${route.path} 对应的启用模块`);
return module;
}
async function resolveDataModule(runtimePage) {
if (runtimePage.b_viewtable) return runtimePage;
const pageId = requireModuleId(runtimePage.b_id);
const response = await loadDataApi(
"s_module",
`b_parent_id = ${pageId} AND b_module_type = 'data' AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
);
const module = response.data?.find((item) => item.b_viewtable);
if (!module) throw new Error("当前页面没有配置可用的数据模块");
return module;
}
async function loadModuleConfig(version) {
configLoading.value = true;
configError.value = "";
try {
const runtimePage = await resolveConfiguredModule();
const runtimeData = await resolveDataModule(runtimePage);
const dataId = requireModuleId(runtimeData.b_id);
const [fieldResponse, listResponse, queryResponse] = await Promise.all([
loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"),
loadDataApi("s_module_field_list", `b_module_id = ${dataId}`, "b_xh ASC, b_id ASC"),
loadDataApi(
"s_module_field_query",
`b_module_id = ${dataId} AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
),
]);
if (disposed || version !== requestVersion) return false;
const fields = (fieldResponse.data || []).filter(
(field) => field.b_canuse === 1 && IDENTIFIER_RE.test(field.b_field || ""),
);
const fieldById = new Map(fields.map((field) => [String(field.b_id), field]));
const lists = (listResponse.data || []).filter(
(item) => item.b_visible === 1 && fieldById.has(String(item.b_field_id)),
);
const queries = (queryResponse.data || [])
.map((config) => {
const field = fieldById.get(String(config.b_field_id));
if (!field) return null;
return { config, field, ...parseOptions(field) };
})
.filter(Boolean);
runtimeData.resolvedKeyField = resolveKeyField(runtimeData, fields);
pageModule.value = runtimePage;
dataModule.value = runtimeData;
activeFields.value = fields;
listConfig.value = lists;
queryItems.value = queries;
commitAppliedFilters(
queries.map((item) => createAppliedFilter(item, defaultQueryValue(item))),
);
return true;
} catch (error) {
if (disposed || version !== requestVersion) return false;
configError.value = error?.message || String(error);
throw error;
} finally {
if (version === requestVersion) configLoading.value = false;
}
}
function resolveOrderBy() {
const configured = String(dataModule.value?.b_orderfield || "").trim();
if (configured) return configured;
const keyField = dataModule.value?.resolvedKeyField;
if (keyField) return `${keyField} DESC`;
const fieldById = new Map(activeFields.value.map((field) => [String(field.b_id), field]));
const firstField = fieldById.get(String(listConfig.value[0]?.b_field_id))?.b_field;
if (IDENTIFIER_RE.test(firstField || "")) return `${firstField} ASC`;
throw new Error("数据模块未配置可用的默认排序字段");
}
async function fetchData(version = requestVersion) {
if (!dataModule.value?.b_viewtable || version !== requestVersion) return;
const dataVersion = ++dataRequestVersion;
dataLoading.value = true;
dataError.value = "";
try {
const fieldById = new Map(activeFields.value.map((field) => [String(field.b_id), field]));
const searchColumns = [
...new Set(
[
dataModule.value.resolvedKeyField,
...listConfig.value.map((item) => fieldById.get(String(item.b_field_id))?.b_field),
].filter(Boolean),
),
];
const response = await pageDataApi({
viewName: dataModule.value.b_viewtable,
orderBy: resolveOrderBy(),
searchColumns,
searchCondition: appliedSearchCondition.value,
pageNo: pagination.current,
pageSize: pagination.pageSize,
});
if (disposed || version !== requestVersion || dataVersion !== dataRequestVersion) return;
dataSource.value = response.data?.rows || [];
pagination.total = Number(response.data?.total ?? response.data?.row_count) || 0;
} catch (error) {
if (disposed || version !== requestVersion || dataVersion !== dataRequestVersion) return;
dataSource.value = [];
pagination.total = 0;
dataError.value = error?.message || String(error);
} finally {
if (version === requestVersion && dataVersion === dataRequestVersion) {
dataLoading.value = false;
}
}
}
async function initialize() {
const version = ++requestVersion;
dataRequestVersion += 1;
dataLoading.value = false;
pagination.current = 1;
dataSource.value = [];
pagination.total = 0;
try {
const loaded = await loadModuleConfig(version);
if (loaded) await fetchData(version);
} catch {
dataSource.value = [];
}
}
function search({ entries = [] }) {
commitAppliedFilters(entries.map(({ item, value }) => createAppliedFilter(item, value)));
pagination.current = 1;
return fetchData();
}
function resetSearch() {
commitAppliedFilters([]);
pagination.current = 1;
return fetchData();
}
function removeFilter(fieldName) {
commitAppliedFilters(appliedFilters.value.filter((filter) => filter.key !== fieldName));
pagination.current = 1;
return fetchData();
}
function changePage(page, pageSize) {
pagination.current = pageSize === pagination.pageSize ? page : 1;
pagination.pageSize = pageSize;
return fetchData();
}
function reload() {
return fetchData();
}
function resolveRowKey(row) {
const keyField = dataModule.value?.resolvedKeyField;
return keyField ? row?.[keyField] : JSON.stringify(row);
}
watch(() => [String(unref(options.moduleCode) || ""), route.path], initialize, {
immediate: true,
});
onBeforeUnmount(() => {
disposed = true;
requestVersion += 1;
dataRequestVersion += 1;
});
return {
pageModule,
dataModule,
activeFields,
listConfig,
queryItems,
dataSource,
appliedFilters,
pagination,
configLoading,
dataLoading,
loading,
configError,
dataError,
initialize,
search,
resetSearch,
removeFilter,
changePage,
reload,
resolveRowKey,
};
}
@@ -1,5 +1,6 @@
<script setup>
import { computed, nextTick, ref, useSlots } from "vue";
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useSlots, watch } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { StkTable } from "stk-table-vue";
import { createSelectionColumn, isSelectionColumn } from "./utils/createSelectionCellRenderers";
import { resolveCustomCell } from "./utils/createCellRenderer";
@@ -12,6 +13,7 @@ const props = defineProps({
colResizable: { type: Boolean, default: true },
bordered: { type: [Boolean, String], default: true },
noDataFull: { type: Boolean, default: true },
showOverflow: { type: Boolean, default: true },
columns: { type: Array, default: () => [] },
// 序号列:false=关闭;true=默认;{ title, width }=自定义
seq: { type: [Boolean, Object], default: false },
@@ -19,13 +21,114 @@ const props = defineProps({
rowSelection: { type: Object, default: null },
dataSource: { type: Array, default: () => [] },
rowKey: { type: [String, Function], default: "id" },
activeRowKey: { type: [String, Number], default: null },
// 行拖拽排序:开启后显示拖拽手柄,并通过 row-order-change 回传排序后的完整数据。
rowDraggable: { type: Boolean, default: false },
// 存在 editor 配置的列默认可编辑;设为 false 可整体禁用编辑。
editable: { type: Boolean, default: true },
});
const emit = defineEmits(["update:columns"]);
const emit = defineEmits(["update:columns", "change", "row-order-change"]);
const rootRef = ref(null);
const tableRef = ref(null);
const slots = useSlots();
// Tab 面板从隐藏恢复时,等到容器拥有有效尺寸再刷新虚拟滚动布局。
// RAF 将同一帧内的多次 ResizeObserver 通知合并,避免重复重排。
const { resume: refreshLayout } = useRafFn(
async () => {
await nextTick();
tableRef.value?.initVirtualScroll?.();
await nextTick();
},
{ immediate: false, once: true },
);
useResizeObserver(rootRef, ([entry]) => {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) refreshLayout();
});
const NAVIGATION_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Tab"]);
const NAVIGATION_IDLE_MS = 80;
const rowEditingStates = new WeakMap();
let activeEditingRow = null;
let pendingEditingTarget = null;
let navigationEvent = null;
let navigationTimer = null;
const pressedNavigationKeys = new Set();
function getRowKey(row) {
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
}
function getRowEditingState(row) {
if (!row || typeof row !== "object") return null;
let state = rowEditingStates.get(row);
if (!state) {
state = shallowRef({ editing: false, focusCol: null, initialValues: null });
rowEditingStates.set(row, state);
}
return state;
}
function captureEditableValues(row) {
return new Map(
props.columns.filter((col) => col.editor).map((col) => [col.dataIndex, row[col.dataIndex]]),
);
}
function clearActiveEditing() {
if (!activeEditingRow) return;
const state = rowEditingStates.get(activeEditingRow);
activeEditingRow = null;
if (state) state.value = { editing: false, focusCol: null, initialValues: null };
}
function activateEditing(row, focusCol) {
if (!props.editable || !row) return;
const state = getRowEditingState(row);
if (!state) return;
if (activeEditingRow !== row) {
clearActiveEditing();
activeEditingRow = row;
state.value = {
editing: true,
focusCol,
initialValues: captureEditableValues(row),
};
return;
}
const current = state.value;
if (current.editing && current.focusCol === focusCol) return;
state.value = { ...current, editing: true, focusCol };
}
function editorValuesEqual(col, previous, current) {
if (col.editor?.type === "select" || col.editor?.type === "checkbox") {
return Object.is(previous, current);
}
return String(previous ?? "") === String(current ?? "");
}
const editingCtx = {
isEditing: (row) => props.editable && getRowEditingState(row)?.value.editing,
shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex,
shouldNotify: (row, col, newValue) => {
const values = getRowEditingState(row)?.value.initialValues;
if (!values?.has(col.dataIndex)) return true;
if (editorValuesEqual(col, values.get(col.dataIndex), newValue)) return false;
values.set(col.dataIndex, newValue);
return true;
},
onChange: (row, col, newValue) => {
emit("change", row, col, newValue);
},
};
// 由 seq 派生:enabled / title / width
const seqConfig = computed(() => {
const enabled = !!props.seq;
@@ -37,10 +140,19 @@ const seqConfig = computed(() => {
width: Number(cfg.width ?? 50),
};
});
const hasRowSelection = computed(() => Boolean(props.rowSelection));
const dragRowColumn = {
type: "dragRow",
dataIndex: "__drag_row__",
title: "",
width: 40,
resizable: false,
};
const displayColumns = computed(() => {
const cols = props.columns.map(resolveCustomCell);
if (selectionColumn) cols.unshift(selectionColumn);
const cols = props.columns.map((col) => resolveCustomCell(col, editingCtx));
if (props.rowDraggable) cols.unshift(dragRowColumn);
if (hasRowSelection.value) cols.unshift(selectionColumn);
const seq = seqConfig.value;
if (seq.enabled) {
cols.unshift({
@@ -54,14 +166,29 @@ const displayColumns = computed(() => {
return cols;
});
// 剥离内部列(选择列、序号列)后回传,避免污染父组件 columns
// 剥离内部列(选择列、序号列、拖拽列)后回传,避免污染父组件 columns
function onColumnsUpdate(columns) {
const real = (columns || []).filter(
(c) => !isSelectionColumn(c) && c.type !== "seq" && c.dataIndex !== "",
const originalByKey = new Map(
props.columns.map((col) => [String(col.key ?? col.dataIndex ?? ""), col]),
);
const real = (columns || [])
.filter(
(c) =>
!isSelectionColumn(c) && c.type !== "seq" && c.type !== "dragRow" && c.dataIndex !== "",
)
.map((col) => {
const original = originalByKey.get(String(col.key ?? col.dataIndex ?? ""));
return original ? { ...original, width: col.width ?? original.width } : col;
});
emit("update:columns", real);
}
function handleRowOrderChange(sourceIndex, targetIndex) {
const rows = tableRef.value?.getTableData?.() ?? props.dataSource;
tableRef.value?.clearSelectedArea?.();
emit("row-order-change", [...rows], sourceIndex, targetIndex);
}
// 行高同时作为 CSS 变量与 StkTable 的 row-height / header-row-height
const rowHeight = 34;
const headerRowHeight = 40;
@@ -71,28 +198,20 @@ const customVars = {
"--footer-row-height": "36px",
};
function getRowKey(row) {
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
}
// 选择列:已选状态管理 + 渲染逻辑全部内聚在 createSelectionColumn 中
const selectionColumn = props.rowSelection
? createSelectionColumn({
getDataSource: () => props.dataSource,
getRowKey,
getSelectedRowKeys: () => props.rowSelection.selectedRowKeys ?? [],
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
})
: null;
const selectionColumn = createSelectionColumn({
getDataSource: () => props.dataSource,
getRowKey,
getSelectedRowKeys: () => props.rowSelection?.selectedRowKeys ?? [],
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
});
// 滚动到指定行(新增后聚焦)
async function scrollToRow(rowKey) {
await nextTick();
const table = tableRef.value;
if (!table) return;
const idx = props.dataSource.findIndex(
(row) => String(getRowKey(row)) === String(rowKey),
);
const idx = props.dataSource.findIndex((row) => String(getRowKey(row)) === String(rowKey));
if (idx < 0) return;
table.scrollTo(idx * rowHeight, null);
}
@@ -101,44 +220,169 @@ function getSelectedArea() {
return tableRef.value?.getSelectedArea?.();
}
defineExpose({ scrollToRow, getSelectedArea });
function clearSelectedArea() {
tableRef.value?.clearSelectedArea?.();
}
function setRowExpand(...args) {
return tableRef.value?.setRowExpand?.(...args);
}
function initVirtualScroll() {
return tableRef.value?.initVirtualScroll?.();
}
function clearNavigationTimer() {
if (navigationTimer) clearTimeout(navigationTimer);
navigationTimer = null;
}
function commitPendingEditing() {
clearNavigationTimer();
const target = pendingEditingTarget;
pendingEditingTarget = null;
if (!target) return;
const row = props.dataSource.find(
(item) => item === target.row || String(getRowKey(item)) === String(getRowKey(target.row)),
);
if (row) activateEditing(row, target.focusCol);
}
function schedulePendingEditing() {
clearNavigationTimer();
navigationTimer = setTimeout(commitPendingEditing, NAVIGATION_IDLE_MS);
}
function handleKeydownCapture(event) {
if (!NAVIGATION_KEYS.has(event.key)) return;
pressedNavigationKeys.add(event.key);
const currentEvent = { repeat: event.repeat };
navigationEvent = currentEvent;
queueMicrotask(() => {
if (navigationEvent === currentEvent) navigationEvent = null;
});
}
function handleKeyupCapture(event) {
if (!NAVIGATION_KEYS.has(event.key)) return;
pressedNavigationKeys.delete(event.key);
if (!pressedNavigationKeys.size) commitPendingEditing();
}
// 选区变化后,目标行进入编辑,活动单元格获得焦点。
function handleAreaSelectionChange(ranges) {
if (!props.editable) return;
const target = ranges?.at(-1)?.index.end;
const row = target ? tableRef.value?.getTableData?.()[target.row] : null;
const col = target ? displayColumns.value[target.col] : null;
if (!row) {
pendingEditingTarget = null;
clearNavigationTimer();
clearActiveEditing();
return;
}
const focusCol = col?.editor ? col.dataIndex : null;
if (navigationEvent?.repeat) {
pendingEditingTarget = { row, focusCol };
clearActiveEditing();
schedulePendingEditing();
return;
}
pendingEditingTarget = null;
clearNavigationTimer();
activateEditing(row, focusCol);
}
watch(
[() => props.activeRowKey, () => props.dataSource],
async ([rowKey]) => {
if (activeEditingRow && !props.dataSource.includes(activeEditingRow)) clearActiveEditing();
await nextTick();
if (rowKey == null) {
tableRef.value?.setCurrentRow?.(undefined, { silent: true });
return;
}
const row = props.dataSource.find((item) => String(getRowKey(item)) === String(rowKey));
tableRef.value?.setCurrentRow?.(row, { silent: true });
},
{ immediate: true },
);
watch(
() => props.editable,
(editable) => {
if (editable) return;
pendingEditingTarget = null;
pressedNavigationKeys.clear();
clearNavigationTimer();
clearActiveEditing();
},
);
onBeforeUnmount(() => {
pressedNavigationKeys.clear();
clearNavigationTimer();
});
defineExpose({
scrollToRow,
getSelectedArea,
clearSelectedArea,
setRowExpand,
initVirtualScroll,
refreshLayout: initVirtualScroll,
});
</script>
<template>
<StkTable
ref="tableRef"
<div
ref="rootRef"
class="fms-table"
v-bind="$attrs"
:style="customVars"
:row-height="rowHeight"
:header-row-height="headerRowHeight"
:virtual="virtual"
:virtual-x="virtualX"
:col-resizable="colResizable"
:columns="displayColumns"
@update:columns="onColumnsUpdate"
:data-source="dataSource"
:row-key="rowKey"
:bordered="bordered"
:no-data-full="noDataFull"
:area-selection="{
enabled:true,
keyboard:true,
ctrl:false,
shift:false
}"
@keydown.capture="handleKeydownCapture"
@keyup.capture="handleKeyupCapture"
>
<template v-if="slots.empty" #empty>
<slot name="empty" />
</template>
<template v-else #empty>
<a-result status="info" title="暂无数据" sub-title="当前没有可显示的内容" size="small"/>
</template>
</StkTable>
<StkTable
ref="tableRef"
class="fms-table__inner"
v-bind="$attrs"
:style="customVars"
:row-height="rowHeight"
:header-row-height="headerRowHeight"
:virtual="virtual"
:virtual-x="virtualX"
:auto-resize="false"
:col-resizable="colResizable"
:columns="displayColumns"
@update:columns="onColumnsUpdate"
:data-source="dataSource"
:drag-row-config="{ mode: rowDraggable ? 'insert' : 'none' }"
:row-key="rowKey"
:bordered="bordered"
:no-data-full="noDataFull"
:show-overflow="showOverflow"
:area-selection="true"
@area-selection-change="handleAreaSelectionChange"
@row-order-change="handleRowOrderChange"
>
<template v-if="slots.expand" #expand="slotProps">
<slot name="expand" 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="当前没有可显示的内容" size="small" />
</template>
</StkTable>
</div>
</template>
<style scoped>
.fms-table {
.fms-table,
.fms-table__inner {
width: 100%;
height: 100%;
}
@@ -208,12 +452,34 @@ defineExpose({ scrollToRow, getSelectedArea });
height: 2px;
background: #fff;
border: none;
border-radius: 1px;
border-radius: 2px;
transform: none;
}
}
}
.fms-cell-editor {
width: 100%;
height: calc(100% - 4px);
min-width: 0;
font-size: 13px;
}
.fms-cell-display {
display: block;
min-width: 0;
}
.fms-table__inner.text-overflow .fms-cell-display {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fms-table__inner td:has(> .fms-cell-editor-root) {
padding-inline: 3px;
}
// actions 列按钮容器
.fms-actions-cell {
display: inline-flex;
@@ -223,11 +489,14 @@ defineExpose({ scrollToRow, getSelectedArea });
// 操作按钮基础样式
.fms-action-btn {
display: inline-flex;
align-items: center;
gap: 3px;
border: none;
background: transparent;
cursor: pointer;
padding: 2px 4px;
color: #ff4d4f;
color: #1677ff;
font-size: 12px;
line-height: 20px;
white-space: nowrap;
@@ -235,6 +504,16 @@ defineExpose({ scrollToRow, getSelectedArea });
&:hover {
opacity: 0.75;
}
&:disabled {
cursor: not-allowed;
color: #bfbfbf;
opacity: 1;
}
&--danger {
color: #ff4d4f;
}
}
// "更多" 展开按钮样式
@@ -1,198 +1,162 @@
import { h, ref } from "vue";
import { Dropdown, Menu, MenuItem, Modal } from "antdv-next";
import { createInputCell } from "./createEditableCellRenderer";
import { Dropdown, Modal } from "antdv-next";
import { createEditableCell } from "./createEditableCellRenderer";
const stop = (e) => e.stopPropagation();
const stop = (event) => event.stopPropagation();
// ──────────────────────────────────────────
// 1. 通用操作触发:支持 confirm 弹窗
// ──────────────────────────────────────────
function triggerAction(action, row) {
if (action.confirm) {
Modal.confirm({
title: action.confirm,
okText: "确定",
okType: "danger",
cancelText: "取消",
onOk: () => action.onClick?.(row),
});
} else {
if (resolveActionState(action.disabled, row)) return;
if (!action.confirm) {
action.onClick?.(row);
return;
}
const confirm = typeof action.confirm === "string" ? { title: action.confirm } : action.confirm;
Modal.confirm({
okText: "确定",
okType: "danger",
cancelText: "取消",
...confirm,
onOk: () => action.onClick?.(row),
});
}
// ──────────────────────────────────────────
// 2. checkbox 类型渲染器
// ──────────────────────────────────────────
function createCheckboxCell(col) {
return ({ row, cellValue }) => {
const checked = cellValue === 1;
return h(
"label",
{
class: checked ? "fms-checkbox checked" : "fms-checkbox",
onClick: stop,
onMousedown: stop,
},
[
h("input", {
type: "checkbox",
checked,
onChange: (e) => {
e.stopPropagation();
row[col.dataIndex] = e.target.checked ? 1 : 0;
col.onChange?.();
},
onClick: stop,
onMousedown: stop,
}),
h("span", { class: "fms-checkbox-box" }),
function resolveActionState(state, row) {
return typeof state === "function" ? Boolean(state(row)) : Boolean(state);
}
function isActionVisible(action, row) {
return action.visible === undefined || resolveActionState(action.visible, row);
}
function actionButton(action, row, className = "fms-action-btn") {
const disabled = resolveActionState(action.disabled, row);
const content = [];
if (action.icon) content.push(h(action.icon, { size: 14 }));
if (action.label || action.type) content.push(action.label || action.type);
return h(
"button",
{
type: "button",
class: [
className,
{
"fms-action-btn--danger":
action.danger || action.type === "danger" || action.type === "delete",
},
],
);
};
title: action.title || action.label,
disabled,
onMousedown: stop,
onClick: (event) => {
event.stopPropagation();
triggerAction(action, row);
},
},
content,
);
}
// ──────────────────────────────────────────
// 3. actions 类型渲染器
// ──────────────────────────────────────────
function createActionsCell(actions) {
if (!actions?.length) return undefined;
const inlineActions = actions.filter((a) => a.type !== "more");
const moreAction = actions.find((a) => a.type === "more");
const moreOpen = ref(false);
const moreRow = ref(null);
const inlineActions = actions.filter((action) => action.type !== "more");
const moreAction = actions.find((action) => action.type === "more");
const openRow = ref(null);
const itemByKey = new Map(
moreAction?.items?.map((item, index) => [String(item.key ?? item.type ?? index), item]) ?? [],
);
function makeButton(action, row) {
return h(
"button",
{
type: "button",
class: "fms-action-btn",
onMousedown: stop,
onClick: (e) => {
e.stopPropagation();
triggerAction(action, row);
},
},
action.label || action.type,
);
function closeMenu() {
openRow.value = null;
}
return ({ row }) => {
const children = [];
const children = inlineActions
.filter((action) => isActionVisible(action, row))
.map((action) => actionButton(action, row));
const visibleMoreItems = (moreAction?.items || []).filter((item) => isActionVisible(item, row));
if (!visibleMoreItems.length) return h("span", { class: "fms-actions-cell" }, children);
for (const action of inlineActions) {
children.push(makeButton(action, row));
const menuItems = visibleMoreItems.map((item, index) => ({
key: String(item.key ?? item.type ?? index),
label: item.label,
icon: item.icon ? h(item.icon, { size: 14 }) : undefined,
disabled: resolveActionState(item.disabled, row),
danger: item.danger || item.type === "danger" || item.type === "delete",
}));
const visibleItemByKey = new Map(
visibleMoreItems.map((item, index) => [String(item.key ?? item.type ?? index), item]),
);
if (openRow.value !== row) {
children.push(
h(
"button",
{
type: "button",
class: "fms-action-btn fms-action-more",
title: moreAction.title || "更多操作",
"aria-label": moreAction.title || "更多操作",
onMousedown: stop,
onClick: (event) => {
event.stopPropagation();
openRow.value = row;
},
},
moreAction.label || "⋯",
),
);
return h("span", { class: "fms-actions-cell" }, children);
}
if (moreAction?.items?.length) {
if (!moreOpen.value) {
children.push(
h(
"button",
{
type: "button",
class: "fms-action-btn fms-action-more",
onMousedown: stop,
onClick: (e) => {
e.stopPropagation();
moreOpen.value = true;
moreRow.value = row;
children.push(
h(
Dropdown,
{
open: true,
trigger: ["click"],
placement: "bottomRight",
menu: {
items: menuItems,
onClick: ({ key }) => {
const item = visibleItemByKey.get(String(key)) || itemByKey.get(String(key));
closeMenu();
if (item) triggerAction(item, row);
},
},
onOpenChange: (open) => {
if (!open) closeMenu();
},
},
{
default: () =>
h(
"button",
{
type: "button",
class: "fms-action-btn fms-action-more fms-action-more-open",
title: moreAction.title || "更多操作",
"aria-label": moreAction.title || "更多操作",
onMousedown: stop,
onClick: stop,
},
},
"更多",
),
);
} else {
children.push(
h(
Dropdown,
{
visible: true,
trigger: "click",
placement: "bottomRight",
onVisibleChange: (v) => {
if (!v) {
moreOpen.value = false;
moreRow.value = null;
}
},
},
{
default: () =>
h(
"button",
{
type: "button",
class: "fms-action-btn fms-action-more fms-action-more-open",
onMousedown: stop,
},
"更多",
),
overlay: () =>
h(Menu, null, {
default: () =>
moreAction.items.map((item) =>
h(MenuItem, { key: item.label }, {
default: () =>
h(
"span",
{
onClick: (e) => {
e.stopPropagation();
moreOpen.value = false;
triggerAction(item, moreRow.value);
},
},
item.label,
),
}),
),
}),
},
),
);
}
}
moreAction.label || "⋯",
),
},
),
);
return h("span", { class: "fms-actions-cell" }, children);
};
}
// ──────────────────────────────────────────
// 4. 统一入口:根据 col.type 注入 customCell
// ──────────────────────────────────────────
const cellCache = new WeakMap();
/**
* 根据列定义的 type 属性自动注入 customCell 渲染器。
* - `type: "input"` → 原生 input(编辑态通过 row._editing 控制,非编辑态零 VNode 纯文本)
* - `type: "checkbox"` → 原生 checkbox
* - `type: "actions"` → 操作按钮列(从 col.actions 读取按钮配置)
* - 无 type 或有自定义 customCell → 保持原样
*
* input 类型不做缓存:因为 customCell 读的是 row._editing 运行时状态,
* 不依赖闭包捕获的外部值,每次创建相同函数亦可安全返回。
* checkbox/actions 使用 WeakMap 缓存,避免重复创建。
*/
export function resolveCustomCell(col) {
export function resolveCustomCell(col, editingContext) {
if (col.customCell) return col;
if (col.editor) return { ...col, customCell: createEditableCell(col, editingContext) };
const cached = cellCache.get(col);
if (cached) return cached;
let result = col;
if (col.type === "input") {
// 不缓存:row._editing 在渲染时动态读取
return { ...col, customCell: createInputCell(col) };
} else if (col.type === "checkbox") {
result = { ...col, customCell: createCheckboxCell(col) };
} else if (col.type === "actions") {
const fn = createActionsCell(col.actions);
if (fn) result = { ...col, customCell: fn };
if (col.actions?.length) {
const customCell = createActionsCell(col.actions);
if (customCell) return { ...col, customCell };
}
cellCache.set(col, result);
return result;
return col;
}
@@ -1,52 +1,150 @@
import { h } from "vue";
import { Input, Select } from "antdv-next";
/**
* 为 type:"input" 的列创建 customCell 渲染器。
*
* 编辑状态通过 row._editing 控制:
* - row._editing = { mode: "cell", col: "b_code" } → 该列显示 <input>(自动聚焦+全选)
* - row._editing = undefined 或 col 不匹配 → 非编辑态,直接返回纯文本字符串(零 VNode 开销)
*
* 退出编辑时调用 col._onEditEnd(),由父组件触发全表重渲染。
*/
export function createInputCell(col) {
return ({ row, cellValue }) => {
const editing = row._editing;
if (!editing || editing.mode !== "cell" || editing.col !== col.dataIndex) {
return cellValue ?? "";
const stop = (event) => event.stopPropagation();
function resolveOptions(editor) {
return typeof editor.options === "function" ? editor.options() : (editor.options ?? []);
}
function displayValue(row, col, editor) {
const value = row[col.dataIndex];
const text =
editor.type === "select"
? (resolveOptions(editor).find((item) => String(item.value) === String(value))?.label ??
value)
: value;
const content = text ?? "";
return h("span", { class: "fms-cell-display", title: String(content) }, content);
}
function notifyChange(ctx, editor, row, col, value) {
if (!ctx.shouldNotify(row, col, value)) return;
editor.onChange?.(value, row, col);
ctx.onChange(row, col, value);
}
function createFocusHandler(ctx, row, col, selectText = false) {
return ({ component, el }) => {
if (!ctx.shouldFocus(row, col)) return;
const input = el?.matches?.("input") ? el : el?.querySelector?.("input");
if (document.activeElement === input) return;
const instance = component?.exposed || component?.proxy;
if (instance?.focus) {
instance.focus(selectText ? { cursor: "all" } : undefined);
return;
}
const exitEdit = () => {
delete row._editing;
col._onEditEnd?.();
};
input?.focus();
if (selectText) input?.select();
};
}
return h("input", {
type: "text",
value: cellValue ?? "",
class: "fms-editable-input",
onInput: (e) => {
row[col.dataIndex] = e.target.value;
},
onBlur: () => {
col.onChange?.();
exitEdit();
},
onKeydown: (e) => {
if (e.key === "Escape") {
col.onChange?.();
exitEdit();
}
if (e.key === "Enter") {
col.onChange?.();
}
},
ref: (el) => {
if (el) {
el.focus();
el.select();
}
},
});
function createInput(row, col, editor, ctx) {
const focus = createFocusHandler(ctx, row, col, true);
return h(Input, {
type: editor.inputType || "text",
value: row[col.dataIndex] ?? "",
size: "small",
class: "fms-cell-editor fms-cell-editor-root",
"aria-label": col.title || col.dataIndex,
"onUpdate:value": (value) => {
row[col.dataIndex] = value;
},
onBlur: (event) => {
const value = event.target.value;
notifyChange(ctx, editor, row, col, value);
},
onKeydown: (event) => {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
event.currentTarget.select();
} else if (event.key === "Escape") {
event.currentTarget.blur();
}
},
onClick: stop,
onVnodeMounted: focus,
onVnodeUpdated: focus,
});
}
function createSelect(row, col, editor, ctx) {
const options = resolveOptions(editor);
const focus = createFocusHandler(ctx, row, col);
return h(Select, {
value: row[col.dataIndex] ?? undefined,
options,
size: "small",
allowClear: editor.allowClear ?? true,
class: "fms-cell-editor fms-cell-editor-root",
"aria-label": col.title || col.dataIndex,
style: { width: "100%" },
getPopupContainer: (trigger) => trigger.parentNode,
"onUpdate:value": (value) => {
const resolved = value ?? (editor.emptyValue ?? null);
row[col.dataIndex] = resolved;
notifyChange(ctx, editor, row, col, resolved);
},
onClick: stop,
onVnodeMounted: focus,
onVnodeUpdated: focus,
});
}
function createCheckbox(row, col, editor, ctx) {
const checked = row[col.dataIndex] === 1;
const focus = ctx.isEditing(row) ? createFocusHandler(ctx, row, col) : undefined;
const update = (value) => {
row[col.dataIndex] = value;
notifyChange(ctx, editor, row, col, value);
};
return h(
"label",
{
class: checked
? "fms-checkbox fms-cell-editor-root checked"
: "fms-checkbox fms-cell-editor-root",
onClick: stop,
},
[
h("input", {
type: "checkbox",
checked,
"aria-label": col.title || col.dataIndex,
onChange: (event) => {
update(event.target.checked ? 1 : 0);
},
onKeydown: (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
event.stopPropagation();
const value = row[col.dataIndex] === 1 ? 0 : 1;
event.currentTarget.checked = value === 1;
update(value);
},
onClick: stop,
onVnodeMounted: focus,
onVnodeUpdated: focus,
}),
h("span", { class: "fms-checkbox-box" }),
],
);
}
export function createEditableCell(col, ctx) {
const editor = col.editor;
return ({ row }) => {
// checkbox 始终可交互,点击即切换,无需先让整行进入编辑态
if (editor?.type === "checkbox") {
return createCheckbox(row, col, editor, ctx);
}
if (!ctx.isEditing(row)) {
return displayValue(row, col, editor);
}
if (editor.type === "select") return createSelect(row, col, editor, ctx);
return createInput(row, col, editor, ctx);
};
}
@@ -18,10 +18,14 @@ const stop = (e) => e.stopPropagation();
export function createSelectionColumn({ getDataSource, getRowKey, getSelectedRowKeys, onChange }) {
// 已选 key 集合,内部响应式计算,仅对外暴露 column 对象
const selectedRowKeySet = computed(() => new Set(getSelectedRowKeys()));
const rowByKey = computed(() => {
const rows = new Map();
for (const row of getDataSource()) rows.set(getRowKey(row), row);
return rows;
});
function updateSelection(nextKeys) {
const keySet = new Set(nextKeys);
const rows = getDataSource().filter((row) => keySet.has(getRowKey(row)));
const rows = nextKeys.map((key) => rowByKey.value.get(key)).filter(Boolean);
onChange(nextKeys, rows);
}
@@ -49,7 +53,11 @@ export function createSelectionColumn({ getDataSource, getRowKey, getSelectedRow
const checked = selectedRowKeySet.value.has(key);
return h(
"label",
{ class: checked ? "fms-checkbox checked" : "fms-checkbox", onClick: stop, onMousedown: stop },
{
class: checked ? "fms-checkbox checked" : "fms-checkbox",
onClick: stop,
onMousedown: stop,
},
[
h("input", {
type: "checkbox",
@@ -66,10 +74,13 @@ export function createSelectionColumn({ getDataSource, getRowKey, getSelectedRow
);
};
// 表头全选复选框:直接使用 Set.size 判断已选数,避免 filter 全表遍历
// 表头只统计当前数据中的选中项,避免筛选后被隐藏的 key 干扰状态。
const SelectionHeaderCell = () => {
const rows = getDataSource();
const selectedCount = selectedRowKeySet.value.size;
let selectedCount = 0;
for (const key of selectedRowKeySet.value) {
if (rowByKey.value.has(key)) selectedCount++;
}
const allChecked = rows.length > 0 && selectedCount === rows.length;
const indeterminate = selectedCount > 0 && selectedCount < rows.length;