20260726225220
This commit is contained in:
1 parent
e3c7c6f8da
commit
a8478d60ed
170 files changed
+6786
-1678
No files matched your search
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user