20260809223428
This commit is contained in:
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;
|
||||
}
|
||||
Reference in new issue
Block a user