20260809223428

This commit is contained in:
oneao committed 2026-08-09 22:34:28 +08:00
1 parent d72ca4193d
commit c4891538a7
466 files changed
+21000 -3339

No files matched your search

@@ -0,0 +1,923 @@
<script setup>
import {
computed,
h,
nextTick,
onActivated,
reactive,
ref,
shallowRef,
useSlots,
watch,
} from "vue";
import { useRoute } from "vue-router";
import { message } from "antdv-next";
import { Columns3, RefreshCw, Save } from "@lucide/vue";
import { loadDataApi, nextIdApi, pageDataApi, saveObjectApi } from "@/services/api";
import { useAppStore } from "@/stores/app";
import {
identifierRe,
buildQueryCondition,
defaultQueryValue,
escapeSqlText,
parseOptions,
requireModuleId,
} from "./moduleListUtils";
import FmsModuleTable from "./FmsModuleTable.vue";
import FmsQueryToolbar from "./FmsQueryToolbar.vue";
const props = defineProps({
dataCode: { type: String, default: "" },
fixedSearchCondition: { type: String, default: "" },
columnExtensions: { type: Object, default: () => ({}) },
rowActions: { type: Array, default: () => [] },
rowSelection: { type: Object, default: null },
rowClassName: { type: Function, default: undefined },
expandable: { type: [Boolean, Object], default: false },
pageSize: { type: Number, default: 20 },
inlineActionLimit: { type: Number, default: 2 },
// 可编辑表格模式:取编辑配置生成行内编辑器,支持保存/批量删除。
editable: { type: Boolean, default: false },
// 行拖拽排序:开启后显示拖拽手柄,拖拽按 orderField 重排并纳入脏保存。
rowDraggable: { type: Boolean, default: false },
// 行拖拽排序字段,仅 rowDraggable 时生效。
orderField: { type: String, default: "b_xh" },
});
const emit = defineEmits([
"row-click",
"cell-click",
"row-action",
"expand",
"loaded",
"dirty-change",
]);
const slots = useSlots();
const route = useRoute();
const appStore = useAppStore();
const tableRef = ref(null);
// keep-alive 中的缓存实例必须保留各自的路由上下文,不能跟随全局 route 变化。
const instanceRoutePath = route.path;
const instanceRouteDataCode = route.meta.dataCode;
// ── 内部状态 ──
const dataModule = ref(null);
const activeFields = shallowRef([]);
const activeFieldById = shallowRef(new Map());
const listConfig = shallowRef([]);
const editConfigById = shallowRef(new Map());
const queryItems = shallowRef([]);
const dataSource = shallowRef([]);
const searchConditions = shallowRef([]);
const keyField = ref("");
// 可编辑表格:脏行追踪(rowKey -> Set<已修改字段>),仅在 editable 模式下使用。
const dirtyRows = shallowRef(new Map());
// 可编辑表格:未持久化的新增行 rowKey 集合(新增行保存时作为 inserts 提交)。
const newRowKeys = shallowRef(new Set());
const saving = ref(false);
const configLoading = ref(false);
const dataLoading = ref(false);
const columnSaving = ref(false);
const configError = ref("");
const dataError = ref("");
const pagination = reactive({
current: 1,
pageSize: Number(props.pageSize) || 20,
total: 0,
});
let dataRequestVersion = 0;
const loading = computed(() => configLoading.value || dataLoading.value);
// ── 外部工具 ──
const moreMenuItems = computed(() => [
{
key: "refresh",
label: appStore.t("common.refreshData", "刷新数据"),
icon: h(RefreshCw, { size: 14 }),
},
{
key: "reset-columns",
label: appStore.t("common.resetColumnSettings", "恢复栏位设置"),
icon: h(Columns3, { size: 14 }),
},
{
key: "save-column-settings",
label: appStore.t("common.saveColumnSettings", "保存栏位设置"),
icon: h(Save, { size: 14 }),
disabled: columnSaving.value,
},
]);
const cellSlotNames = computed(() => Object.keys(slots).filter((name) => name.startsWith("cell-")));
// ── 模块配置加载 ──
function resolveKeyField(module, fields) {
if (identifierRe.test(module.b_keyfield || "")) return module.b_keyfield;
for (const field of fields) {
if (field.b_field === "b_id") return field.b_field;
}
for (const field of fields) {
if (identifierRe.test(field.b_field || "")) return field.b_field;
}
return "";
}
function getDataCode() {
return String(props.dataCode || instanceRouteDataCode || "").trim();
}
async function loadModuleConfig() {
const code = getDataCode();
configLoading.value = true;
configError.value = "";
try {
let runtimeData = null;
if (code) {
const moduleRes = await loadDataApi(
"s_module",
`b_code = N'${escapeSqlText(code)}' AND b_module_type = 'data' AND b_canuse = 1`,
"b_id ASC",
);
runtimeData = moduleRes.data?.[0];
if (!runtimeData) throw new Error(`未找到编码为 ${code} 的启用数据模块`);
// 向上追溯找到所属的 page(可能经过 data_category 层级)
let parentId = requireModuleId(runtimeData.b_parent_id);
let page = null;
const maxDepth = 10;
for (let i = 0; i < maxDepth; i++) {
const res = await loadDataApi(
"s_module",
`b_id = ${parentId} AND b_canuse = 1`,
"b_id ASC",
);
const node = res.data?.[0];
if (!node) break;
if (node.b_module_type === "page") {
page = node;
break;
}
if (node.b_parent_id == null) break;
parentId = requireModuleId(node.b_parent_id);
}
if (!page) {
throw new Error(`数据编码 ${runtimeData.b_code} 没有对应的启用页面`);
}
} else {
const routePath = escapeSqlText(instanceRoutePath);
const pageRes = await loadDataApi(
"s_module",
`b_route = N'${routePath}' AND b_canuse = 1`,
"b_id ASC",
);
const page = pageRes.data?.[0];
if (!page) throw new Error(`未找到路由 ${instanceRoutePath} 对应的启用模块`);
if (page.b_viewtable) {
runtimeData = page;
} else {
// 递归查找 page 下首个有 b_viewtable 的 data 模块(可能经过 data_category 层级)
const queue = [requireModuleId(page.b_id)];
const visited = new Set();
let found = false;
while (queue.length && !found) {
const curId = queue.shift();
if (visited.has(curId)) continue;
visited.add(curId);
const childrenRes = await loadDataApi(
"s_module",
`b_parent_id = ${curId} AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
);
for (const child of childrenRes.data || []) {
if (child.b_module_type === "data" && child.b_viewtable) {
runtimeData = child;
found = true;
break;
}
if (child.b_module_type === "data_category") {
queue.push(requireModuleId(child.b_id));
}
}
}
if (!runtimeData) throw new Error("当前页面没有配置可用的数据模块");
}
}
const dataId = requireModuleId(runtimeData.b_id);
// 列显示配置:普通模式取列表配置,可编辑模式取编辑配置,二者只取其一。
const listTable = props.editable ? "s_module_field_edit" : "s_module_field_list";
const responses = await Promise.all([
loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"),
loadDataApi(listTable, `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 (getDataCode() !== code) return false;
const fields = [];
const fieldById = new Map();
for (const field of responses[0].data || []) {
if (field.b_canuse !== 1 || !identifierRe.test(field.b_field || "")) continue;
fields.push(field);
fieldById.set(String(field.b_id), field);
}
// 可编辑模式下:记录编辑配置的 readonly/disabled,用于生成只读编辑器。
const editConfigByIdMap = new Map();
for (const item of props.editable ? responses[1].data || [] : []) {
if (item.b_visible !== 1) continue;
if (!fieldById.has(String(item.b_field_id))) continue;
editConfigByIdMap.set(String(item.b_field_id), item);
}
editConfigById.value = editConfigByIdMap;
// 列表配置:普通模式为 s_module_field_list,可编辑模式为 s_module_field_edit。
const lists = [];
for (const item of responses[1].data || []) {
if (item.b_visible !== 1) continue;
if (!fieldById.has(String(item.b_field_id))) continue;
lists.push(item);
}
const queries = [];
for (const config of responses[2].data || []) {
const field = fieldById.get(String(config.b_field_id));
if (!field) continue;
const optionInfo = parseOptions(field);
queries.push({
config,
field,
optionConfig: optionInfo.optionConfig,
options: optionInfo.options,
});
}
const conditions = [];
for (const item of queries) {
const condition = buildQueryCondition(item, defaultQueryValue(item));
if (condition) conditions.push(condition);
}
dataModule.value = runtimeData;
activeFields.value = fields;
activeFieldById.value = fieldById;
listConfig.value = lists;
queryItems.value = queries;
searchConditions.value = conditions;
keyField.value = resolveKeyField(runtimeData, fields);
dirtyRows.value = new Map();
newRowKeys.value = new Set();
return true;
} catch (error) {
if (getDataCode() === code) {
configError.value = error?.message || String(error);
}
return false;
} finally {
if (getDataCode() === code) {
configLoading.value = false;
}
}
}
// ── 数据查询 ──
function resolveOrderBy() {
if (!dataModule.value) throw new Error("数据模块尚未加载");
const configured = String(dataModule.value.b_orderfield || "").trim();
if (configured) return configured;
if (keyField.value) return `${keyField.value} DESC`;
return "b_id DESC";
}
async function fetchData() {
if (!dataModule.value || !dataModule.value.b_viewtable) return;
const currentModule = dataModule.value;
const viewName = currentModule.b_viewtable;
const currentCode = getDataCode();
const requestVersion = ++dataRequestVersion;
const conditions = [];
const fixedCondition = String(props.fixedSearchCondition || "").trim();
if (fixedCondition) conditions.push(`(${fixedCondition})`);
conditions.push(...searchConditions.value);
dataLoading.value = true;
dataError.value = "";
try {
const res = await pageDataApi({
viewName,
orderBy: resolveOrderBy(),
searchColumns: [],
searchCondition: conditions.join(" AND "),
pageNo: pagination.current,
pageSize: pagination.pageSize,
});
if (requestVersion !== dataRequestVersion || dataModule.value !== currentModule) return;
if (getDataCode() !== currentCode) return;
dataSource.value = res.data?.rows || [];
pagination.total = Number(res.data?.total ?? res.data?.row_count) || 0;
} catch (error) {
if (
requestVersion === dataRequestVersion &&
dataModule.value === currentModule &&
getDataCode() === currentCode
) {
dataSource.value = [];
pagination.total = 0;
dataError.value = error?.message || String(error);
}
} finally {
if (
requestVersion === dataRequestVersion &&
dataModule.value === currentModule &&
getDataCode() === currentCode
) {
dataLoading.value = false;
}
}
}
// ── 公开方法 ──
async function initialize() {
dataRequestVersion++;
dataModule.value = null;
activeFields.value = [];
activeFieldById.value = new Map();
listConfig.value = [];
editConfigById.value = new Map();
queryItems.value = [];
searchConditions.value = [];
keyField.value = "";
dirtyRows.value = new Map();
newRowKeys.value = new Set();
saving.value = false;
dataLoading.value = false;
dataError.value = "";
pagination.current = 1;
dataSource.value = [];
pagination.total = 0;
const loaded = await loadModuleConfig();
if (loaded) await fetchData();
}
function search({ entries = [] }) {
if (loading.value) return;
const conditions = [];
for (const entry of entries) {
const condition = buildQueryCondition(entry.item, entry.value);
if (condition) conditions.push(condition);
}
searchConditions.value = conditions;
pagination.current = 1;
return fetchData();
}
function resetSearch() {
if (loading.value) return;
searchConditions.value = [];
pagination.current = 1;
return fetchData();
}
function changePage(page, pageSize) {
if (loading.value) return;
pagination.current = pageSize === pagination.pageSize ? page : 1;
pagination.pageSize = pageSize;
return fetchData();
}
function reload() {
if (loading.value) return;
return fetchData();
}
async function resetColumns() {
await nextTick();
if (tableRef.value) tableRef.value.resetColumns();
}
async function saveColumnSettings() {
if (loading.value || columnSaving.value || !tableRef.value) return;
if (typeof tableRef.value.getColumnSettings !== "function") return;
const settings = tableRef.value.getColumnSettings();
if (!settings.length) {
message.info("当前没有可保存的栏位设置");
return;
}
const updates = [];
for (const row of settings) {
updates.push({
b_id: row.b_id,
b_xh: row.b_xh,
b_width: row.b_width,
});
}
columnSaving.value = true;
try {
// 可编辑表格的列来自编辑配置,栏位设置保存到 s_module_field_edit;普通模式保存到列表配置。
const table = props.editable ? "s_module_field_edit" : "s_module_field_list";
await saveObjectApi([
{
table,
key_field: "b_id",
updates,
},
]);
const currentById = new Map();
for (const row of listConfig.value) {
currentById.set(String(row.b_id), row);
}
const savedListConfig = [];
for (const saved of settings) {
const current = currentById.get(String(saved.b_id));
if (!current) continue;
savedListConfig.push({ ...current, b_xh: saved.b_xh, b_width: saved.b_width });
}
listConfig.value = savedListConfig;
message.success("栏位设置已保存");
} catch (error) {
let errorMessage = error;
if (error && error.message) errorMessage = error.message;
message.error("栏位设置保存失败:" + errorMessage);
} finally {
columnSaving.value = false;
}
}
function resolveRowKey(row) {
if (keyField.value) return row[keyField.value];
return JSON.stringify(row);
}
// ── 可编辑表格:脏行追踪 / 保存 / 批量删除 ──
// 主键与系统审计字段(由后端维护)在可编辑表格中一律只读。
const SYSTEM_FIELDS = ["b_inputdatetime", "b_inputuser_id", "b_updatedatetime", "b_updateuser_id"];
const readonlyFields = computed(() => {
const fields = new Set(SYSTEM_FIELDS);
if (keyField.value) fields.add(keyField.value);
return [...fields];
});
function handleCellChange({ row, field }) {
if (!props.editable || !keyField.value) return;
const rowKey = String(resolveRowKey(row));
const current = dirtyRows.value.get(rowKey);
if (current) {
current.add(field);
} else {
dirtyRows.value.set(rowKey, new Set([field]));
}
emit("dirty-change", { count: dirtyRows.value.size });
}
function resolveSaveTable() {
if (!dataModule.value) throw new Error("数据模块尚未加载");
return String(dataModule.value.b_savetable || dataModule.value.b_viewtable || "");
}
function dirtyUpdateRows() {
if (!keyField.value) return [];
const updates = [];
const rowByKey = new Map(
dataSource.value.map((row) => [String(resolveRowKey(row)), row]),
);
for (const [rowKey, fields] of dirtyRows.value) {
// 未持久化的新增行走 inserts 分支,避免对不存在的记录执行 UPDATE。
if (newRowKeys.value.has(rowKey)) continue;
const row = rowByKey.get(rowKey);
if (!row) continue;
const update = { [keyField.value]: row[keyField.value] };
for (const field of fields) {
if (field === keyField.value) continue;
update[field] = row[field];
}
updates.push(update);
}
return updates;
}
// 未持久化的新增行:仅收集有实际修改的行,作为 inserts 提交。
function dirtyInsertRows() {
if (!keyField.value) return [];
const rowByKey = new Map(
dataSource.value.map((row) => [String(resolveRowKey(row)), row]),
);
const inserts = [];
for (const rowKey of newRowKeys.value) {
const fields = dirtyRows.value.get(rowKey);
const row = rowByKey.get(rowKey);
if (!fields || !row) continue;
const insert = { [keyField.value]: row[keyField.value] };
for (const field of fields) {
if (field === keyField.value) continue;
insert[field] = row[field];
}
inserts.push(insert);
}
return inserts;
}
// 找到第一个可编辑的列字段(按编辑配置顺序,跳过只读列、主键与 checkbox),
// 用于新增行后定位光标聚焦的单元格。
function firstEditableField() {
const readonly = new Set(readonlyFields.value);
for (const config of listConfig.value) {
const field = activeFieldById.value.get(String(config.b_field_id));
if (!field) continue;
if (readonly.has(field.b_field)) continue;
if (field.b_type === "checkbox") continue;
return field.b_field;
}
return null;
}
// 可编辑模式下新增一行:生成雪花主键,追加到表尾并让第一个可编辑单元格进入编辑态并聚焦。
async function addRow() {
if (!props.editable || saving.value || loading.value) return;
if (!keyField.value) {
message.error("该模块未配置主键,无法新增");
return;
}
const idRes = await nextIdApi();
const newId = String(idRes.data);
const newRow = { [keyField.value]: newId };
// 排序字段取当前最大值 + 10;checkbox 字段默认 0。
if (props.orderField && identifierRe.test(props.orderField)) {
let maxXh = 0;
for (const row of dataSource.value) {
maxXh = Math.max(maxXh, Number(row[props.orderField]) || 0);
}
newRow[props.orderField] = maxXh + 10;
}
for (const field of activeFields.value) {
if (field.b_field === keyField.value || field.b_field === props.orderField) continue;
if (newRow[field.b_field] !== undefined) continue;
if (field.b_type === "checkbox") newRow[field.b_field] = 0;
}
dataSource.value = [...dataSource.value, newRow];
newRowKeys.value = new Set([...newRowKeys.value, newId]);
await nextTick();
// 聚焦第一个可编辑字段,让用户可直接输入。
tableRef.value?.editRow?.(newId, firstEditableField());
}
async function saveChanges() {
if (saving.value) return;
const table = resolveSaveTable();
if (!table) {
message.error("该模块未配置保存表,无法保存");
return;
}
const updates = dirtyUpdateRows();
const inserts = dirtyInsertRows();
if (!updates.length && !inserts.length) {
message.info("没有需要保存的修改");
return;
}
saving.value = true;
try {
const operation = { table, key_field: keyField.value };
if (updates.length) operation.updates = updates;
if (inserts.length) operation.inserts = inserts;
await saveObjectApi([operation]);
// 已提交的新增行不再视为未持久化。
const insertedSet = new Set(inserts.map((row) => String(row[keyField.value])));
newRowKeys.value = new Set(
[...newRowKeys.value].filter((key) => !insertedSet.has(key)),
);
dirtyRows.value = new Map();
// 保存成功后让正在编辑的行退出编辑态。
tableRef.value?.clearEditing?.();
message.success("保存成功");
emit("dirty-change", { count: 0 });
await fetchData();
} catch (error) {
message.error("保存失败:" + (error?.message || error));
} finally {
saving.value = false;
}
}
async function deleteSelected(rowKeys) {
if (saving.value || !rowKeys?.length) return;
const table = resolveSaveTable();
if (!table) {
message.error("该模块未配置保存表,无法删除");
return;
}
if (!keyField.value) {
message.error("该模块未配置主键,无法删除");
return;
}
// 未持久化的新增行直接本地移除,不调删除接口。
const newSet = new Set(newRowKeys.value);
const pendingKeys = rowKeys.filter((key) => newSet.has(String(key)));
const persistedKeys = rowKeys.filter((key) => !newSet.has(String(key)));
if (pendingKeys.length) {
const pendingSet = new Set(pendingKeys.map((key) => String(key)));
dataSource.value = dataSource.value.filter(
(row) => !pendingSet.has(String(resolveRowKey(row))),
);
const remaining = new Map();
for (const [rowKey, fields] of dirtyRows.value) {
if (!pendingSet.has(rowKey)) remaining.set(rowKey, fields);
}
dirtyRows.value = remaining;
newRowKeys.value = new Set(
[...newRowKeys.value].filter((key) => !pendingSet.has(key)),
);
tableRef.value?.clearEditing?.();
emit("dirty-change", { count: dirtyRows.value.size });
}
if (!persistedKeys.length) {
message.success(`已删除 ${rowKeys.length} 条记录`);
return;
}
saving.value = true;
try {
await saveObjectApi([
{
table,
key_field: keyField.value,
deletes: persistedKeys.map((key) => ({ [keyField.value]: key })),
},
]);
// 清除被删行的脏记录
const deletedSet = new Set(persistedKeys.map((key) => String(key)));
const remaining = new Map();
for (const [rowKey, fields] of dirtyRows.value) {
if (!deletedSet.has(rowKey)) remaining.set(rowKey, fields);
}
dirtyRows.value = remaining;
// 删除成功后让正在编辑的行退出编辑态。
tableRef.value?.clearEditing?.();
message.success(`已删除 ${rowKeys.length} 条记录`);
await fetchData();
} catch (error) {
message.error("删除失败:" + (error?.message || error));
} finally {
saving.value = false;
}
}
// 行拖拽排序:回灌排序结果,按 orderField 重新编号并标记为脏,随保存一起提交。
function handleRowOrderChange(orderedRows) {
if (!props.rowDraggable || !props.orderField) return;
if (!identifierRe.test(props.orderField)) return;
const rowByOld = new Map(
dataSource.value.map((row) => [String(resolveRowKey(row)), row]),
);
const dirtyFieldsByKey = new Map();
const nextRows = [];
let order = 10;
for (const row of orderedRows) {
const key = String(resolveRowKey(row));
const oldRow = rowByOld.get(key);
const oldValue = oldRow ? Number(oldRow[props.orderField]) || 0 : 0;
const copy = { ...row, [props.orderField]: order };
nextRows.push(copy);
if (oldValue !== order) {
const fields = dirtyFieldsByKey.get(key) || new Set();
fields.add(props.orderField);
dirtyFieldsByKey.set(key, fields);
}
order += 10;
}
dataSource.value = nextRows;
for (const [key, fields] of dirtyFieldsByKey) {
const current = dirtyRows.value.get(key);
if (current) {
fields.forEach((field) => current.add(field));
} else {
dirtyRows.value.set(key, fields);
}
}
emit("dirty-change", { count: dirtyRows.value.size });
}
async function handleMoreAction({ key }) {
if (key === "refresh") {
await reload();
return;
}
if (key === "reset-columns") {
await resetColumns();
message.success(appStore.t("common.columnSettingsReset", "已恢复栏位设置"));
return;
}
if (key === "save-column-settings") {
await saveColumnSettings();
}
}
// ── 生命周期 ──
let hasActivated = false;
void initialize();
onActivated(async () => {
if (!hasActivated) {
hasActivated = true;
return;
}
await nextTick();
tableRef.value?.refreshLayout?.();
});
watch(() => props.dataCode, initialize);
watch(
() => props.fixedSearchCondition,
() => {
if (!dataModule.value) return;
pagination.current = 1;
fetchData();
},
);
watch(
() => dataSource.value,
(rows) => {
emit("loaded", { rows, total: pagination.total, module: dataModule.value });
},
);
defineExpose({
reload,
resetColumns,
initialize,
dataSource,
dataModule,
pagination,
saveChanges,
deleteSelected,
addRow,
dirtyRows,
});
</script>
<template>
<div class="fms-module-list-page">
<div v-if="configError" class="module-config-error">
<a-result
status="error"
:title="appStore.t('common.moduleConfigLoadFailed', '模块配置加载失败')"
:sub-title="configError"
>
<template #extra>
<a-button type="primary" @click="initialize">
{{ appStore.t("common.reload", "重新加载") }}
</a-button>
</template>
</a-result>
</div>
<template v-else>
<FmsQueryToolbar
:query-items="queryItems"
:loading="loading"
:more-items="moreMenuItems"
@search="search"
@reset="resetSearch"
@more-action="handleMoreAction"
>
<template #actions>
<slot
name="actions"
:reload="reload"
:data-module="dataModule"
:loading="loading"
:editable="editable"
:saving="saving"
:dirty-count="dirtyRows.size"
:save-changes="saveChanges"
/>
</template>
</FmsQueryToolbar>
<section class="module-table-panel">
<div class="module-table-stage">
<FmsModuleTable
ref="tableRef"
:fields="activeFields"
:list-config="listConfig"
:edit-config-by-id="editConfigById"
:readonly-fields="readonlyFields"
:data-source="dataSource"
:row-key="resolveRowKey"
:column-extensions="columnExtensions"
:row-actions="rowActions"
:row-selection="rowSelection"
:row-class-name="rowClassName"
:expandable="expandable"
:inline-action-limit="inlineActionLimit"
:editable="editable"
:row-draggable="rowDraggable"
@row-click="$emit('row-click', $event)"
@cell-click="$emit('cell-click', $event)"
@row-action="$emit('row-action', $event)"
@expand="$emit('expand', $event)"
@cell-change="handleCellChange"
@row-order-change="handleRowOrderChange"
>
<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="loading" class="module-table-loading">
<a-spin />
</div>
</div>
<div v-if="dataError" class="module-data-error">
{{ dataError }}
</div>
<a-pagination
:current="pagination.current"
:page-size="pagination.pageSize"
:total="pagination.total"
:show-total="
(total) =>
`${appStore.t('common.total', '共')} ${total} ${appStore.t('common.items', '条')}`
"
:disabled="loading"
show-size-changer
@change="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-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,379 @@
<script setup>
import { computed, h, ref, shallowRef, useSlots, watch } from "vue";
import { Checkbox } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { useAppStore } from "@/stores/app";
import { formatDateValue, parseOptions } from "./moduleListUtils";
const props = defineProps({
fields: { type: Array, default: () => [] },
listConfig: { type: Array, default: () => [] },
editConfigById: { type: [Object, Map], default: () => new Map() },
readonlyFields: { type: Array, default: () => [] },
dataSource: { type: Array, default: () => [] },
rowKey: { type: [String, Function], default: "b_id" },
columnExtensions: { type: Object, default: () => ({}) },
rowActions: { type: Array, default: () => [] },
rowSelection: { type: Object, default: null },
rowClassName: { type: Function, default: undefined },
expandable: { type: [Boolean, Object], default: false },
inlineActionLimit: { type: Number, default: 2 },
editable: { type: Boolean, default: false },
// 行拖拽排序:开启后显示拖拽手柄,通过 row-order-change 回传排序后的完整数据。
rowDraggable: { type: Boolean, default: false },
});
const emit = defineEmits([
"row-click",
"cell-click",
"row-action",
"expand",
"cell-change",
"row-order-change",
]);
const appStore = useAppStore();
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 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 === "checkbox") {
return h(Checkbox, { checked: value === true || Number(value) === 1 });
}
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.optionConfig.precision)
? optionInfo.optionConfig.precision
: 2;
return number.toLocaleString("zh-CN", {
minimumFractionDigits: precision,
maximumFractionDigits: precision,
});
}
return String(value);
}
function resolveExtension(fieldName) {
return props.columnExtensions?.[fieldName] || {};
}
// 根据字段类型与编辑配置生成行内编辑器配置。
function buildEditor(field, config) {
if (
props.readonlyFields.includes(field.b_field) ||
(config && (Number(config.b_readonly) === 1 || Number(config.b_disabled) === 1))
) {
return { type: "input", disabled: true };
}
if (field.b_type === "checkbox") return { type: "checkbox" };
if (field.b_type === "select") {
const { options } = parseOptions(field);
return { type: "select", options };
}
return { type: "input" };
}
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 editConfig = props.editable
? props.editConfigById.get(String(config.b_field_id))
: null;
const column = {
title: appStore.t(field.b_i18n, field.b_name || field.b_field),
dataIndex: field.b_field,
key: field.b_field,
width: Number(config.b_width) || 120,
checkbox: field.b_type === "checkbox",
align: field.b_type === "checkbox" ? "center" : undefined,
editor: props.editable ? buildEditor(field, editConfig) : undefined,
...columnProps,
};
// 可编辑模式下由 editor 接管单元格渲染(resolveCustomCell 会优先 editor 分支),
// 不能再设置 customCell,否则编辑器永远不会被渲染。
if (props.editable) return column;
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: appStore.t("common.moreActions", "更多操作"),
items: overflowActions,
});
}
return {
dataIndex: "__actions__",
key: "__actions__",
title: appStore.t("common.operation", "操作"),
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 refreshLayout() {
tableRef.value?.refreshLayout?.();
}
function getColumnSettings() {
const configByFieldId = new Map();
for (const config of props.listConfig || []) {
configByFieldId.set(String(config.b_field_id), config);
}
const settings = [];
let order = 10;
for (const column of columns.value) {
if (!column.dataIndex || String(column.dataIndex).startsWith("__")) continue;
const field = props.fields.find((item) => item.b_field === column.dataIndex);
if (!field) continue;
const config = configByFieldId.get(String(field.b_id));
if (!config) continue;
const parsedWidth = Number.parseFloat(String(column.width));
let columnWidth = config.b_width;
if (Number.isFinite(parsedWidth) && parsedWidth > 0) {
columnWidth = Math.round(parsedWidth);
}
settings.push({
...config,
b_xh: order,
b_width: columnWidth,
});
order += 10;
}
return settings;
}
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 handleCellChange(row, col, value) {
emit("cell-change", { row, field: col.dataIndex, value });
}
function handleToggleExpand(payload) {
const single = expansionOptions.value.mode !== "multiple";
if (payload.expanded && single && expandedRecord && expandedRecord !== payload.row) {
tableRef.value?.setRowExpand?.(expandedRecord, false, { silent: true });
}
if (payload.expanded) {
expandedRecord = payload.row;
} else if (expandedRecord === payload.row) {
expandedRecord = null;
}
emit("expand", { expanded: payload.expanded, record: payload.row, column: payload.col });
}
watch(
[
() => props.fields,
() => props.listConfig,
() => props.columnExtensions,
() => props.rowActions,
() => props.editable,
() => props.editConfigById,
() => appStore.locale,
expansionEnabled,
],
rebuildColumns,
{ immediate: true, deep: true },
);
watch(
() => props.dataSource,
() => {
expandedRecord = null;
},
);
defineExpose({
resetColumns,
refreshLayout,
getColumnSettings,
clearSelectedArea: () => tableRef.value?.clearSelectedArea?.(),
clearEditing: () => tableRef.value?.clearEditing?.(),
editRow: (rowKey, focusCol) => tableRef.value?.editRow?.(rowKey, focusCol),
scrollToRow: (rowKey) => tableRef.value?.scrollToRow?.(rowKey),
});
</script>
<template>
<FmsTable
ref="tableRef"
v-model:columns="columns"
:data-source="dataSource"
:row-key="rowKey"
:row-selection="rowSelection"
:row-class-name="rowClassName"
:expand-config="expansionEnabled ? expandConfig : undefined"
:editable="editable"
:row-draggable="rowDraggable"
@cell-click="handleCellClick"
@row-click="handleRowClick"
@toggle-row-expand="handleToggleExpand"
@change="handleCellChange"
@row-order-change="(rows, sourceIndex, targetIndex) => emit('row-order-change', rows, sourceIndex, targetIndex)"
>
<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,186 @@
<script setup>
import { computed } from "vue";
import { useAppStore } from "@/stores/app";
import { isDateQuery, isEmptyValue, isNumericField, isRangeQuery } from "./moduleListUtils";
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 appStore = useAppStore();
const isRange = computed(() => isRangeQuery(props.item));
const isNumeric = computed(() => isNumericField(props.item));
const isDate = computed(() => isDateQuery(props.item));
const isCheckbox = computed(() => props.item.field.b_type === "checkbox");
const checkboxOptions = computed(() => [
{ label: appStore.t("common.all", "全部"), value: "" },
{ label: appStore.t("common.yes", "是"), value: "1" },
{ label: appStore.t("common.no", "否"), value: "0" },
]);
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 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-radio-group
v-else-if="isCheckbox"
v-model:value="value"
class="fms-query-control__boolean"
:options="checkboxOptions"
option-type="button"
/>
<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__boolean {
display: flex;
width: 100%;
:deep(.ant-radio-button-wrapper) {
min-width: 42px;
flex: 1;
padding-inline: 8px;
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,368 @@
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { ChevronDown, ChevronUp, Ellipsis, RotateCcw, Search } from "@lucide/vue";
import { useAppStore } from "@/stores/app";
import FmsQueryControl from "./FmsQueryControl.vue";
import {
defaultQueryValue,
emptyQueryValue,
isDateQuery,
isNumericField,
isRangeQuery,
} from "./moduleListUtils";
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 appStore = useAppStore();
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: appStore.t(item.field.b_i18n, 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 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="appStore.t('common.search', '搜索')"
@click="handleQuickSearch"
>
<Search />
</a-button>
</a-space-compact>
<a-tooltip
v-if="queryItems.length"
:title="
advancedOpen
? appStore.t('common.collapseAdvancedSearch', '收起高级搜索')
: appStore.t('common.advancedSearch', '高级搜索')
"
>
<a-button
shape="circle"
:class="{ 'toolbar-icon-button--active': advancedOpen }"
:aria-label="
advancedOpen
? appStore.t('common.collapseAdvancedSearch', '收起高级搜索')
: appStore.t('common.advancedSearch', '高级搜索')
"
@click="toggleAdvancedSearch"
>
<Search />
</a-button>
</a-tooltip>
<a-dropdown
v-if="moreItems.length"
:disabled="loading"
:menu="{ items: moreItems, onClick: (event) => $emit('more-action', event) }"
:trigger="['click']"
placement="bottomRight"
>
<a-tooltip :title="appStore.t('common.more', '更多')">
<a-button
shape="circle"
:aria-label="appStore.t('common.more', '更多')"
>
<Ellipsis />
</a-button>
</a-tooltip>
</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="appStore.t(item.field.b_i18n, 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" />
<ChevronDown v-else />
{{
advancedMoreOpen
? appStore.t("common.collapseConditions", "收起条件")
: `${appStore.t("common.moreConditions", "更多条件")} (${hiddenAdvancedQueryCount})`
}}
</a-button>
</div>
<div class="query-actions">
<a-button type="primary" :loading="loading" @click="handleAdvancedSearch">
<Search />
{{ appStore.t("common.search", "查询") }}
</a-button>
<a-button type="text" :disabled="loading" @click="handleAdvancedReset">
<RotateCcw />
{{ appStore.t("common.reset", "重置") }}
</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 0;
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 {
&--active {
border-color: #91caff;
background: #e6f4ff;
color: #1677ff;
}
}
.more-trigger {
display: inline-flex;
}
.query-panel {
display: grid;
width: min(950px, calc(100% - 24px));
align-self: flex-start;
grid-template-columns: minmax(0, 1fr) 172px;
gap: 14px;
padding: 10px 12px 12px;
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;
margin-left: 12px;
align-self: center;
justify-content: space-between;
gap: 8px;
> :deep(.ant-btn) {
width: 76px;
}
}
</style>
@@ -0,0 +1,176 @@
// 数据驱动列表的共享工具函数,避免在各组件间重复实现查询/格式化逻辑。
export const identifierRe = /^[A-Za-z_][A-Za-z0-9_]*$/;
export function escapeSqlText(value) {
return String(value).replaceAll("'", "''");
}
export function requireModuleId(value) {
const id = String(value ?? "");
if (!/^\d+$/.test(id)) throw new Error("模块 ID 格式不正确");
return id;
}
export function isEmptyValue(value) {
return value === undefined || value === null || value === "";
}
export function parseOptions(field) {
if (!field?.b_options) return { optionConfig: {}, options: [] };
try {
const parsed = JSON.parse(field.b_options);
let optionConfig = {};
let source = [];
if (Array.isArray(parsed)) {
source = parsed;
} else if (parsed && typeof parsed === "object") {
optionConfig = parsed;
if (Array.isArray(parsed.options)) {
source = parsed.options;
} else if (Array.isArray(parsed.items)) {
source = 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: [] };
}
}
export function isRangeQuery(item) {
return (
item.config.b_operator === "between" ||
item.config.b_component === "number-range" ||
item.field.b_type === "datetime"
);
}
export function isNumericField(item) {
return item.field.b_type === "number" || item.field.b_type === "money";
}
export function isDateQuery(item) {
return (
item.config.b_component === "date-picker" ||
item.field.b_type === "date" ||
item.field.b_type === "datetime"
);
}
export function emptyQueryValue(item) {
if (item.field.b_type === "checkbox") return "";
if (isRangeQuery(item) || item.config.b_operator === "in") return [];
return undefined;
}
export 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;
}
export 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 === "checkbox") {
return value === true || Number(value) === 1 ? "1" : "0";
}
let normalized = String(value);
const isDate = field.b_type === "date" || field.b_type === "datetime";
if (isDate && value && typeof value.format === "function") {
normalized = value.format("YYYY-MM-DD");
} else if (isDate) {
normalized = String(value).slice(0, 10);
}
if (field.b_type === "datetime") {
normalized += endOfRange ? " 23:59:59" : " 00:00:00";
}
return `N'${escapeSqlText(normalized)}'`;
}
export function buildQueryCondition(item, value) {
const fieldName = item.field.b_field;
if (!identifierRe.test(fieldName)) return "";
const field = `[${fieldName}]`;
if (isRangeQuery(item)) {
const values = Array.isArray(value) ? value : [];
const start = sqlLiteral(values[0], item.field);
const end = sqlLiteral(values[1], item.field, true);
if (start && end) return `${field} BETWEEN ${start} AND ${end}`;
if (start) return `${field} >= ${start}`;
if (end) return `${field} <= ${end}`;
return "";
}
if (item.config.b_operator === "in") {
let values = value;
if (!Array.isArray(values)) {
values = String(value ?? "")
.split(",")
.map((part) => part.trim())
.filter(Boolean);
}
const literals = [];
for (const entry of values) {
const literal = sqlLiteral(entry, item.field);
if (literal) literals.push(literal);
}
return literals.length ? `${field} IN (${literals.join(", ")})` : "";
}
const literal = sqlLiteral(value, item.field);
if (!literal) return "";
if (item.config.b_operator === "like") {
return `${field} LIKE N'%${escapeSqlText(String(value))}%'`;
}
return `${field} = ${literal}`;
}
export 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;
}