20260727173703

This commit is contained in:
oneao committed 2026-07-27 17:37:03 +08:00
1 parent a8478d60ed
commit 9dbaef7787
114 files changed
+959 -2588

No files matched your search

@@ -0,0 +1,55 @@
<script setup>
defineProps({
loading: { type: Boolean, default: false },
size: {
type: String,
default: "large",
validator: (value) => ["small", "default", "large"].includes(value),
},
description: { type: String, default: "" },
});
</script>
<template>
<div class="fms-loading-overlay" :aria-busy="loading">
<div class="fms-loading-overlay__content" :class="{ 'is-loading': loading }">
<slot />
</div>
<div v-show="loading" class="fms-loading-overlay__mask">
<a-spin :size="size" :description="description || undefined" />
</div>
</div>
</template>
<style scoped>
.fms-loading-overlay,
.fms-loading-overlay__content {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.fms-loading-overlay {
position: relative;
width: 100%;
height: 100%;
}
.fms-loading-overlay__content.is-loading {
pointer-events: none;
user-select: none;
}
.fms-loading-overlay__mask {
position: absolute;
z-index: 100;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--fms-surface, #fff) 72%, transparent);
cursor: wait;
}
</style>
@@ -7,7 +7,7 @@ import FmsQueryToolbar from "./FmsQueryToolbar.vue";
import { useModuleList } from "./useModuleList";
const props = defineProps({
moduleCode: { type: String, default: "" },
dataCode: { type: String, default: "" },
columnExtensions: { type: Object, default: () => ({}) },
rowActions: { type: Array, default: () => [] },
rowClassName: { type: Function, default: undefined },
@@ -20,7 +20,7 @@ const emit = defineEmits(["row-click", "cell-click", "row-action", "expand", "lo
const slots = useSlots();
const tableRef = ref(null);
const moduleList = useModuleList({
moduleCode: toRef(props, "moduleCode"),
dataCode: toRef(props, "dataCode"),
pageSize: props.pageSize,
});
@@ -240,17 +240,29 @@ export function useModuleList(options = {}) {
appliedFilters.value = filters.filter(Boolean);
}
async function resolveConfiguredModule() {
const moduleCode = String(unref(options.moduleCode) || "").trim();
if (moduleCode) {
async function resolvePageModule(runtimeData) {
const pageId = requireModuleId(runtimeData.b_parent_id);
const response = await loadDataApi(
"s_module",
`b_id = ${pageId} AND b_module_type = 'page' AND b_canuse = 1`,
"b_id ASC",
);
const module = response.data?.[0];
if (!module) throw new Error(`数据编码 ${runtimeData.b_code} 没有对应的启用页面`);
return module;
}
async function resolveConfiguredModules() {
const dataCode = String(unref(options.dataCode) || route.meta.dataCode || "").trim();
if (dataCode) {
const response = await loadDataApi(
"s_module",
`b_code = N'${escapeSqlText(moduleCode)}' AND b_canuse = 1`,
`b_code = N'${escapeSqlText(dataCode)}' AND b_module_type = 'data' AND b_canuse = 1`,
"b_id ASC",
);
const module = response.data?.[0];
if (!module) throw new Error(`未找到编码为 ${moduleCode} 的启用模块`);
return module;
const runtimeData = response.data?.[0];
if (!runtimeData) throw new Error(`未找到编码为 ${dataCode} 的启用数据模块`);
return { runtimePage: await resolvePageModule(runtimeData), runtimeData };
}
const routePath = escapeSqlText(route.path);
@@ -259,9 +271,9 @@ export function useModuleList(options = {}) {
`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;
const runtimePage = response.data?.[0];
if (!runtimePage) throw new Error(`未找到路由 ${route.path} 对应的启用模块`);
return { runtimePage, runtimeData: await resolveDataModule(runtimePage) };
}
async function resolveDataModule(runtimePage) {
@@ -281,8 +293,7 @@ export function useModuleList(options = {}) {
configLoading.value = true;
configError.value = "";
try {
const runtimePage = await resolveConfiguredModule();
const runtimeData = await resolveDataModule(runtimePage);
const { runtimePage, runtimeData } = await resolveConfiguredModules();
const dataId = requireModuleId(runtimeData.b_id);
const [fieldResponse, listResponse, queryResponse] = await Promise.all([
loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"),
@@ -426,9 +437,13 @@ export function useModuleList(options = {}) {
return keyField ? row?.[keyField] : JSON.stringify(row);
}
watch(() => [String(unref(options.moduleCode) || ""), route.path], initialize, {
immediate: true,
});
watch(
() => [String(unref(options.dataCode) || route.meta.dataCode || ""), route.path],
initialize,
{
immediate: true,
},
);
onBeforeUnmount(() => {
disposed = true;
@@ -51,12 +51,9 @@ useResizeObserver(rootRef, ([entry]) => {
});
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) {
@@ -232,41 +229,80 @@ function initVirtualScroll() {
return tableRef.value?.initVirtualScroll?.();
}
function clearNavigationTimer() {
if (navigationTimer) clearTimeout(navigationTimer);
navigationTimer = null;
let editActivateTimer = null;
let lastEditActivateAt = 0;
let isRepeatNavigation = false;
function clearEditActivateTimer() {
if (editActivateTimer) {
clearTimeout(editActivateTimer);
editActivateTimer = null;
}
}
// 键盘导航停止(松开按键或停顿)后再激活编辑器,合并连续/按住按键,
// 避免滚动过程中每帧创建/销毁编辑器组件导致掉帧。
function scheduleEditActivation() {
clearEditActivateTimer();
editActivateTimer = setTimeout(() => {
editActivateTimer = null;
commitPendingEditing();
}, 100);
}
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);
if (row) {
activateEditing(row, target.focusCol);
lastEditActivateAt = performance.now();
}
}
function schedulePendingEditing() {
clearNavigationTimer();
navigationTimer = setTimeout(commitPendingEditing, NAVIGATION_IDLE_MS);
function removeNavigationListeners() {
window.removeEventListener("keyup", handleKeyupCapture, true);
window.removeEventListener("blur", handleWindowBlur);
}
function handleKeydownCapture(event) {
if (!NAVIGATION_KEYS.has(event.key)) return;
if (!pressedNavigationKeys.size) {
window.addEventListener("keyup", handleKeyupCapture, true);
window.addEventListener("blur", handleWindowBlur);
}
pressedNavigationKeys.add(event.key);
const currentEvent = { repeat: event.repeat };
navigationEvent = currentEvent;
queueMicrotask(() => {
if (navigationEvent === currentEvent) navigationEvent = null;
});
// event.repeat=true 表示系统自动重复(按住方向键不松),用于区分“按住滚动”与“离散点按”
if (event.repeat) isRepeatNavigation = true;
}
function handleKeyupCapture(event) {
if (!NAVIGATION_KEYS.has(event.key)) return;
pressedNavigationKeys.delete(event.key);
if (!pressedNavigationKeys.size) commitPendingEditing();
if (!pressedNavigationKeys.delete(event.key) || pressedNavigationKeys.size) return;
removeNavigationListeners();
if (isRepeatNavigation) {
// 长按已经结束,立即恢复最终目标行,不再额外等待防抖时间。
isRepeatNavigation = false;
clearEditActivateTimer();
commitPendingEditing();
return;
}
// 快速离散连按仍使用短防抖,避免连续挂载/卸载整行编辑器。
if (pendingEditingTarget) scheduleEditActivation();
}
function handleWindowBlur() {
removeNavigationListeners();
pressedNavigationKeys.clear();
isRepeatNavigation = false;
clearEditActivateTimer();
commitPendingEditing();
}
// 选区变化后,目标行进入编辑,活动单元格获得焦点。
@@ -278,22 +314,41 @@ function handleAreaSelectionChange(ranges) {
const col = target ? displayColumns.value[target.col] : null;
if (!row) {
pendingEditingTarget = null;
clearNavigationTimer();
clearActiveEditing();
clearEditActivateTimer();
return;
}
const focusCol = col?.editor ? col.dataIndex : null;
if (navigationEvent?.repeat) {
pendingEditingTarget = { row, focusCol };
clearActiveEditing();
schedulePendingEditing();
// 键盘导航期间(按下方向键等):
// - 按住方向键滚动(event.repeat=true):全程保持 0 编辑器,避免长按下反复创建组件导致卡顿/闪烁;
// 松手后再激活到最终行。
// - 慢速离散点按(非 repeat):立即激活,编辑器逐行跟随、跟手;
// - 快速连按(间隔 <150ms):防抖合并,避免连按时每键挂载掉帧。
if (pressedNavigationKeys.size > 0) {
if (isRepeatNavigation) {
pendingEditingTarget = { row, focusCol };
clearActiveEditing();
} else {
const now = performance.now();
if (now - lastEditActivateAt >= 150) {
pendingEditingTarget = null;
clearEditActivateTimer();
activateEditing(row, focusCol);
lastEditActivateAt = now;
} else {
pendingEditingTarget = { row, focusCol };
scheduleEditActivation();
}
}
return;
}
// 非键盘导航(鼠标点击等):立即激活
pendingEditingTarget = null;
clearNavigationTimer();
clearEditActivateTimer();
activateEditing(row, focusCol);
lastEditActivateAt = performance.now();
}
watch(
@@ -316,15 +371,19 @@ watch(
(editable) => {
if (editable) return;
pendingEditingTarget = null;
removeNavigationListeners();
pressedNavigationKeys.clear();
clearNavigationTimer();
isRepeatNavigation = false;
clearEditActivateTimer();
clearActiveEditing();
tableRef.value?.clearSelectedArea?.();
},
);
onBeforeUnmount(() => {
removeNavigationListeners();
clearEditActivateTimer();
pressedNavigationKeys.clear();
clearNavigationTimer();
});
defineExpose({
@@ -338,12 +397,7 @@ defineExpose({
</script>
<template>
<div
ref="rootRef"
class="fms-table"
@keydown.capture="handleKeydownCapture"
@keyup.capture="handleKeyupCapture"
>
<div ref="rootRef" class="fms-table" @keydown.capture="handleKeydownCapture">
<StkTable
ref="tableRef"
class="fms-table__inner"
@@ -363,7 +417,7 @@ defineExpose({
:bordered="bordered"
:no-data-full="noDataFull"
:show-overflow="showOverflow"
:area-selection="true"
:area-selection="editable"
@area-selection-change="handleAreaSelectionChange"
@row-order-change="handleRowOrderChange"
>
@@ -31,12 +31,13 @@ function createFocusHandler(ctx, row, col, selectText = false) {
if (document.activeElement === input) return;
const instance = component?.exposed || component?.proxy;
const opts = selectText ? { cursor: "all", preventScroll: true } : { preventScroll: true };
if (instance?.focus) {
instance.focus(selectText ? { cursor: "all" } : undefined);
instance.focus(opts);
return;
}
input?.focus();
input?.focus(opts);
if (selectText) input?.select();
};
}
@@ -84,7 +85,7 @@ function createSelect(row, col, editor, ctx) {
style: { width: "100%" },
getPopupContainer: (trigger) => trigger.parentNode,
"onUpdate:value": (value) => {
const resolved = value ?? (editor.emptyValue ?? null);
const resolved = value ?? editor.emptyValue ?? null;
row[col.dataIndex] = resolved;
notifyChange(ctx, editor, row, col, resolved);
},