20260728225443

This commit is contained in:
oneao committed 2026-07-28 22:54:43 +08:00
1 parent 5ec7aadef9
commit f6b79ea09a
30 files changed
+1541 -1120

No files matched your search

@@ -7,7 +7,6 @@ const props = defineProps({
modelValue: { type: String, default: "" },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false },
allowClear: { type: Boolean, default: true },
maxlength: { type: Number, default: undefined },
popupWidth: { type: [Number, String], default: 420 },
popupHeight: { type: [Number, String], default: 180 },
@@ -139,7 +138,6 @@ onBeforeUnmount(() => document.removeEventListener("pointerdown", onDocumentPoin
:value="modelValue"
:disabled="disabled"
:readonly="readonly"
:allow-clear="allowClear"
:maxlength="maxlength"
@update:value="updateValue"
@change="onInputChange"
@@ -1,15 +1,26 @@
<script setup>
import { computed, h, nextTick, ref, toRef, useSlots, watch } from "vue";
import { computed, h, nextTick, reactive, ref, shallowRef, useSlots, watch } from "vue";
import { useRoute } from "vue-router";
import { message } from "antdv-next";
import { Columns3, RefreshCw } from "@lucide/vue";
import { loadDataApi, pageDataApi } 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";
import { useModuleList } from "./useModuleList";
const props = defineProps({
dataCode: { 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 },
@@ -18,25 +29,304 @@ const props = defineProps({
const emit = defineEmits(["row-click", "cell-click", "row-action", "expand", "loaded"]);
const slots = useSlots();
const route = useRoute();
const appStore = useAppStore();
const tableRef = ref(null);
const moduleList = useModuleList({
dataCode: toRef(props, "dataCode"),
pageSize: props.pageSize,
// ── 内部状态 ──
const dataModule = ref(null);
const activeFields = shallowRef([]);
const activeFieldById = shallowRef(new Map());
const listConfig = shallowRef([]);
const queryItems = shallowRef([]);
const dataSource = shallowRef([]);
const searchConditions = shallowRef([]);
const keyField = ref("");
const configLoading = ref(false);
const dataLoading = ref(false);
const configError = ref("");
const dataError = ref("");
const pagination = reactive({
current: 1,
pageSize: Number(props.pageSize) || 20,
total: 0,
});
const moreMenuItems = [
{ key: "refresh", label: "刷新数据", icon: h(RefreshCw, { size: 14 }) },
{ key: "reset-columns", label: "恢复配置列宽", icon: h(Columns3, { size: 14 }) },
];
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.resetColumns", "恢复配置列宽"),
icon: h(Columns3, { size: 14 }),
},
]);
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 || route.meta.dataCode || "").trim();
}
async function loadModuleConfig() {
const code = getDataCode();
const currentPath = route.path;
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} 的启用数据模块`);
const pageId = requireModuleId(runtimeData.b_parent_id);
const pageRes = await loadDataApi(
"s_module",
`b_id = ${pageId} AND b_module_type = 'page' AND b_canuse = 1`,
"b_id ASC",
);
if (!pageRes.data?.[0]) {
throw new Error(`数据编码 ${runtimeData.b_code} 没有对应的启用页面`);
}
} else {
const routePath = escapeSqlText(currentPath);
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(`未找到路由 ${currentPath} 对应的启用模块`);
if (page.b_viewtable) {
runtimeData = page;
} else {
const pageId = requireModuleId(page.b_id);
const dataRes = await loadDataApi(
"s_module",
`b_parent_id = ${pageId} AND b_module_type = 'data' AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
);
for (const item of dataRes.data || []) {
if (item.b_viewtable) {
runtimeData = item;
break;
}
}
if (!runtimeData) throw new Error("当前页面没有配置可用的数据模块");
}
}
const dataId = requireModuleId(runtimeData.b_id);
const responses = await Promise.all([
loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"),
loadDataApi("s_module_field_list", `b_module_id = ${dataId}`, "b_xh ASC, b_id ASC"),
loadDataApi(
"s_module_field_query",
`b_module_id = ${dataId} AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
),
]);
if (getDataCode() !== code || route.path !== currentPath) 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);
}
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);
return true;
} catch (error) {
if (getDataCode() === code && route.path === currentPath) {
configError.value = error?.message || String(error);
}
return false;
} finally {
if (getDataCode() === code && route.path === currentPath) {
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`;
if (listConfig.value.length > 0) {
const firstConfig = listConfig.value[0];
const firstField = activeFieldById.value.get(String(firstConfig.b_field_id));
if (firstField && identifierRe.test(firstField.b_field || "")) {
return `${firstField.b_field} ASC`;
}
}
throw new Error("数据模块未配置可用的默认排序字段");
}
async function fetchData() {
if (!dataModule.value || !dataModule.value.b_viewtable || dataLoading.value) return;
const currentModule = dataModule.value;
const viewName = currentModule.b_viewtable;
const currentCode = getDataCode();
const currentPath = route.path;
dataLoading.value = true;
dataError.value = "";
try {
const res = await pageDataApi({
viewName,
orderBy: resolveOrderBy(),
searchColumns: [],
searchCondition: searchConditions.value.join(" AND "),
pageNo: pagination.current,
pageSize: pagination.pageSize,
});
if (dataModule.value !== currentModule) return;
if (getDataCode() !== currentCode || route.path !== currentPath) return;
dataSource.value = res.data?.rows || [];
pagination.total = Number(res.data?.total ?? res.data?.row_count) || 0;
} catch (error) {
if (
dataModule.value === currentModule &&
getDataCode() === currentCode &&
route.path === currentPath
) {
dataSource.value = [];
pagination.total = 0;
dataError.value = error?.message || String(error);
}
} finally {
if (
dataModule.value === currentModule &&
getDataCode() === currentCode &&
route.path === currentPath
) {
dataLoading.value = false;
}
}
}
// ── 公开方法 ──
async function initialize() {
dataModule.value = null;
activeFields.value = [];
activeFieldById.value = new Map();
listConfig.value = [];
queryItems.value = [];
searchConditions.value = [];
keyField.value = "";
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() {
return moduleList.reload();
if (loading.value) return;
return fetchData();
}
async function resetColumns() {
await nextTick();
tableRef.value?.resetColumns?.();
if (tableRef.value) tableRef.value.resetColumns();
}
function resolveRowKey(row) {
if (keyField.value) return row[keyField.value];
return JSON.stringify(row);
}
async function handleMoreAction({ key }) {
@@ -46,57 +336,57 @@ async function handleMoreAction({ key }) {
}
if (key === "reset-columns") {
await resetColumns();
message.success("已恢复配置列宽");
message.success(appStore.t("common.columnsReset", "已恢复配置列宽"));
}
}
// ── 生命周期 ──
watch(() => [getDataCode(), route.path], initialize, { immediate: true });
watch(
() => moduleList.dataSource.value,
() => dataSource.value,
(rows) => {
emit("loaded", {
rows,
total: moduleList.pagination.total,
module: moduleList.dataModule.value,
});
emit("loaded", { rows, total: pagination.total, module: dataModule.value });
},
);
defineExpose({
reload,
resetColumns,
initialize: moduleList.initialize,
dataSource: moduleList.dataSource,
dataModule: moduleList.dataModule,
pagination: moduleList.pagination,
initialize,
dataSource,
dataModule,
pagination,
});
</script>
<template>
<div class="fms-module-list-page">
<div v-if="moduleList.configError.value" class="module-config-error">
<a-result status="error" title="模块配置加载失败" :sub-title="moduleList.configError.value">
<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="moduleList.initialize">重新加载</a-button>
<a-button type="primary" @click="initialize">
{{ appStore.t("common.reload", "重新加载") }}
</a-button>
</template>
</a-result>
</div>
<template v-else>
<FmsQueryToolbar
:query-items="moduleList.queryItems.value"
:loading="moduleList.dataLoading.value"
:query-items="queryItems"
:loading="loading"
:more-items="moreMenuItems"
@search="moduleList.search"
@reset="moduleList.resetSearch"
@search="search"
@reset="resetSearch"
@more-action="handleMoreAction"
>
<template #actions>
<slot
name="actions"
:reload="reload"
:data-module="moduleList.dataModule.value"
:loading="moduleList.loading.value"
/>
<slot name="actions" :reload="reload" :data-module="dataModule" :loading="loading" />
</template>
</FmsQueryToolbar>
@@ -104,12 +394,13 @@ defineExpose({
<div class="module-table-stage">
<FmsModuleTable
ref="tableRef"
:fields="moduleList.activeFields.value"
:list-config="moduleList.listConfig.value"
:data-source="moduleList.dataSource.value"
:row-key="moduleList.resolveRowKey"
:fields="activeFields"
:list-config="listConfig"
: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"
@@ -129,22 +420,26 @@ defineExpose({
</template>
</FmsModuleTable>
<div v-if="moduleList.loading.value" class="module-table-loading">
<div v-if="loading" class="module-table-loading">
<a-spin />
</div>
</div>
<div v-if="moduleList.dataError.value" class="module-data-error">
{{ moduleList.dataError.value }}
<div v-if="dataError" class="module-data-error">
{{ dataError }}
</div>
<a-pagination
:current="moduleList.pagination.current"
:page-size="moduleList.pagination.pageSize"
:total="moduleList.pagination.total"
:show-total="(total) => `共 ${total} 条`"
: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="moduleList.changePage"
@change="changePage"
/>
</section>
</template>
@@ -1,6 +1,8 @@
<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({
@@ -10,12 +12,14 @@ const props = defineProps({
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 },
});
const emit = defineEmits(["row-click", "cell-click", "row-action", "expand"]);
const appStore = useAppStore();
const slots = useSlots();
const tableRef = ref(null);
const columns = shallowRef([]);
@@ -36,10 +40,8 @@ function formatCellValue(value, field, optionInfo) {
if (value === null || value === undefined || value === "") return "-";
if (field.b_type === "date") return formatDateValue(value, false);
if (field.b_type === "datetime") return formatDateValue(value, true);
if (field.b_type === "switch") {
return value === true || Number(value) === 1
? (optionInfo.optionConfig.trueLabel ?? "是")
: (optionInfo.optionConfig.falseLabel ?? "否");
if (field.b_type === "checkbox") {
return h(Checkbox, { checked: value === true || Number(value) === 1, disabled: true });
}
if (field.b_type === "select") {
const option = optionInfo.options.find((item) => String(item.value) === String(value));
@@ -69,10 +71,12 @@ function createRuntimeColumn(config, field) {
const { formatter, clickable, cellClassName, onClick: _onClick, ...columnProps } = extension;
const slot = slots[`cell-${field.b_field}`];
const column = {
title: field.b_name || field.b_field,
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,
...columnProps,
};
@@ -141,12 +145,16 @@ function createActionColumn() {
const inlineActions = actions.slice(0, inlineLimit);
const overflowActions = actions.slice(inlineLimit);
if (overflowActions.length) {
inlineActions.push({ type: "more", title: "更多操作", items: overflowActions });
inlineActions.push({
type: "more",
title: appStore.t("common.moreActions", "更多操作"),
items: overflowActions,
});
}
return {
dataIndex: "__actions__",
key: "__actions__",
title: "操作",
title: appStore.t("common.operation", "操作"),
width: Math.max(70, inlineActions.length * 52),
align: "center",
fixed: "right",
@@ -215,11 +223,11 @@ function handleToggleExpand(payload) {
if (payload.expanded && single && expandedRecord && expandedRecord !== payload.row) {
tableRef.value?.setRowExpand?.(expandedRecord, false, { silent: true });
}
expandedRecord = payload.expanded
? payload.row
: expandedRecord === payload.row
? null
: expandedRecord;
if (payload.expanded) {
expandedRecord = payload.row;
} else if (expandedRecord === payload.row) {
expandedRecord = null;
}
emit("expand", { expanded: payload.expanded, record: payload.row, column: payload.col });
}
@@ -229,6 +237,7 @@ watch(
() => props.listConfig,
() => props.columnExtensions,
() => props.rowActions,
() => appStore.locale,
expansionEnabled,
],
rebuildColumns,
@@ -251,6 +260,7 @@ defineExpose({ resetColumns, clearSelectedArea: () => tableRef.value?.clearSelec
v-model:columns="columns"
:data-source="dataSource"
:row-key="rowKey"
:row-selection="rowSelection"
:row-class-name="rowClassName"
:expand-config="expansionEnabled ? expandConfig : undefined"
:editable="false"
@@ -1,5 +1,6 @@
<script setup>
import { computed } from "vue";
import { useAppStore } from "@/stores/app";
import { isDateQuery, isEmptyValue, isNumericField, isRangeQuery } from "./moduleListUtils";
const props = defineProps({
@@ -9,10 +10,17 @@ const props = defineProps({
});
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),
@@ -92,6 +100,13 @@ function isDateBeforeLimit(current) {
</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"
@@ -131,6 +146,18 @@ function isDateBeforeLimit(current) {
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;
@@ -1,6 +1,7 @@
<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,
@@ -18,6 +19,7 @@ const props = defineProps({
});
const emit = defineEmits(["search", "reset", "more-action"]);
const appStore = useAppStore();
const filters = reactive({});
const quickFieldName = ref("");
@@ -29,7 +31,7 @@ const quickFieldOptions = computed(() =>
props.queryItems.map((item) => ({
key: item.field.b_field,
value: item.field.b_field,
label: item.field.b_name || item.field.b_field,
label: appStore.t(item.field.b_i18n, item.field.b_name || item.field.b_field),
})),
);
const quickItem = computed(() =>
@@ -133,19 +135,30 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
type="primary"
class="quick-submit"
:loading="loading"
aria-label="搜索"
:aria-label="appStore.t('common.search', '搜索')"
@click="handleQuickSearch"
>
<Search :size="16" />
</a-button>
</a-space-compact>
<a-tooltip v-if="queryItems.length" :title="advancedOpen ? '收起高级搜索' : '高级搜索'">
<a-tooltip
v-if="queryItems.length"
:title="
advancedOpen
? appStore.t('common.collapseAdvancedSearch', '收起高级搜索')
: appStore.t('common.advancedSearch', '高级搜索')
"
>
<a-button
shape="circle"
class="toolbar-icon-button"
:class="{ 'toolbar-icon-button--active': advancedOpen }"
:aria-label="advancedOpen ? '收起高级搜索' : '高级搜索'"
:aria-label="
advancedOpen
? appStore.t('common.collapseAdvancedSearch', '收起高级搜索')
: appStore.t('common.advancedSearch', '高级搜索')
"
@click="toggleAdvancedSearch"
>
<Search :size="16" />
@@ -154,13 +167,18 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
<a-dropdown
v-if="moreItems.length"
:disabled="loading"
:menu="{ items: moreItems, onClick: (event) => $emit('more-action', event) }"
:trigger="['click']"
placement="bottomRight"
>
<span class="more-trigger">
<a-tooltip title="更多">
<a-button shape="circle" class="toolbar-icon-button" aria-label="更多">
<a-tooltip :title="appStore.t('common.more', '更多')">
<a-button
shape="circle"
class="toolbar-icon-button"
:aria-label="appStore.t('common.more', '更多')"
>
<Ellipsis :size="17" />
</a-button>
</a-tooltip>
@@ -176,7 +194,7 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
v-for="item in visibleAdvancedQueryItems"
:key="item.field.b_field"
:class="{ 'query-field--wide': isRangeQuery(item) }"
:label="item.field.b_name"
:label="appStore.t(item.field.b_i18n, item.field.b_name)"
>
<FmsQueryControl
v-model="filters[item.field.b_field]"
@@ -195,18 +213,22 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
>
<ChevronUp v-if="advancedMoreOpen" :size="15" />
<ChevronDown v-else :size="15" />
{{ advancedMoreOpen ? "收起条件" : `更多条件 (${hiddenAdvancedQueryCount})` }}
{{
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 :size="15" />
查询
{{ appStore.t("common.search", "查询") }}
</a-button>
<a-button :disabled="loading" @click="handleAdvancedReset">
<RotateCcw :size="15" />
重置
{{ appStore.t("common.reset", "重置") }}
</a-button>
</div>
</a-form>
@@ -295,9 +317,9 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
.query-panel {
display: grid;
width: min(1100px, calc(100% - 24px));
width: min(950px, calc(100% - 24px));
align-self: flex-start;
grid-template-columns: minmax(0, 1fr) 160px;
grid-template-columns: minmax(0, 1fr) 172px;
gap: 14px;
padding: 10px 12px 12px;
border-left: 1px solid var(--fms-border, #e5e7eb);
@@ -350,6 +372,7 @@ watch(() => props.queryItems, initializeDrafts, { immediate: true });
.query-actions {
display: flex;
width: 160px;
margin-left: 12px;
align-self: center;
justify-content: space-between;
gap: 8px;
@@ -1,6 +1,6 @@
// 数据驱动列表的共享工具函数,避免在各组件间重复实现查询/格式化逻辑。
export const IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
export const identifierRe = /^[A-Za-z_][A-Za-z0-9_]*$/;
export function escapeSqlText(value) {
return String(value).replaceAll("'", "''");
@@ -20,14 +20,18 @@ export function parseOptions(field) {
if (!field?.b_options) return { optionConfig: {}, options: [] };
try {
const parsed = JSON.parse(field.b_options);
const optionConfig = Array.isArray(parsed) ? {} : parsed || {};
const source = Array.isArray(parsed)
? parsed
: Array.isArray(parsed?.options)
? parsed.options
: Array.isArray(parsed?.items)
? parsed.items
: [];
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;
@@ -62,6 +66,7 @@ export function isDateQuery(item) {
}
export function emptyQueryValue(item) {
if (item.field.b_type === "checkbox") return "";
if (isRangeQuery(item) || item.config.b_operator === "in") return [];
return undefined;
}
@@ -88,6 +93,72 @@ export function defaultQueryValue(item) {
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") {
@@ -1,390 +0,0 @@
import { computed, onBeforeUnmount, reactive, ref, shallowRef, unref, watch } from "vue";
import { useRoute } from "vue-router";
import { loadDataApi, pageDataApi } from "@/services/api";
import {
IDENTIFIER_RE,
defaultQueryValue,
escapeSqlText,
formatDateValue,
isEmptyValue,
isRangeQuery,
parseOptions,
requireModuleId,
} from "./moduleListUtils";
function formatSqlDate(value, fieldType, endOfRange = false) {
if (value && typeof value.format === "function") {
if (fieldType === "datetime") {
return `${value.format("YYYY-MM-DD")} ${endOfRange ? "23:59:59" : "00:00:00"}`;
}
return value.format("YYYY-MM-DD");
}
const date = String(value).slice(0, 10);
if (fieldType === "datetime" && /^\d{4}-\d{2}-\d{2}$/.test(date)) {
return `${date} ${endOfRange ? "23:59:59" : "00:00:00"}`;
}
return date;
}
function sqlLiteral(value, field, endOfRange = false) {
if (isEmptyValue(value)) return null;
if (field.b_type === "number" || field.b_type === "money") {
const number = Number(value);
return Number.isFinite(number) ? String(number) : null;
}
if (field.b_type === "switch") return value === true || Number(value) === 1 ? "1" : "0";
const normalized =
field.b_type === "date" || field.b_type === "datetime"
? formatSqlDate(value, field.b_type, endOfRange)
: String(value);
return `N'${escapeSqlText(normalized)}'`;
}
function buildQueryCondition(item, value) {
const fieldName = item.field.b_field;
if (!IDENTIFIER_RE.test(fieldName)) return "";
const identifier = `[${fieldName}]`;
const operator = item.config.b_operator;
if (isRangeQuery(item)) {
const [start, end] = Array.isArray(value) ? value : [];
const startLiteral = sqlLiteral(start, item.field);
const endLiteral = sqlLiteral(end, item.field, true);
if (startLiteral && endLiteral) {
return `${identifier} BETWEEN ${startLiteral} AND ${endLiteral}`;
}
if (startLiteral) return `${identifier} >= ${startLiteral}`;
if (endLiteral) return `${identifier} <= ${endLiteral}`;
return "";
}
if (operator === "in") {
const values = Array.isArray(value)
? value
: String(value ?? "")
.split(",")
.map((part) => part.trim())
.filter(Boolean);
const literals = values.map((entry) => sqlLiteral(entry, item.field)).filter(Boolean);
return literals.length ? `${identifier} IN (${literals.join(", ")})` : "";
}
const literal = sqlLiteral(value, item.field);
if (!literal) return "";
if (operator === "like") return `${identifier} LIKE N'%${escapeSqlText(String(value))}%'`;
return `${identifier} = ${literal}`;
}
function displayQueryValue(item, value) {
if (isEmptyValue(value)) return "";
if (item.field.b_type === "date" || item.field.b_type === "datetime") {
return formatDateValue(value);
}
const option = item.options.find((entry) => String(entry.value) === String(value));
return option?.label ?? String(value);
}
function formatAppliedFilterValue(item, value) {
if (isRangeQuery(item)) {
const [start, end] = Array.isArray(value) ? value : [];
const startText = displayQueryValue(item, start);
const endText = displayQueryValue(item, end);
if (startText && endText) return `${startText} - ${endText}`;
if (startText) return `${startText} 起`;
if (endText) return `${endText} 止`;
return "";
}
if (Array.isArray(value)) {
return value
.map((entry) => displayQueryValue(item, entry))
.filter(Boolean)
.join("、");
}
return displayQueryValue(item, value);
}
function createAppliedFilter(item, value) {
const condition = buildQueryCondition(item, value);
if (!condition) return null;
return {
key: item.field.b_field,
label: item.field.b_name || item.field.b_field,
value: formatAppliedFilterValue(item, value),
condition,
};
}
function resolveKeyField(module, activeFields) {
if (IDENTIFIER_RE.test(module?.b_keyfield || "")) return module.b_keyfield;
return (
activeFields.find((field) => field.b_field === "b_id")?.b_field ||
activeFields.find((field) => IDENTIFIER_RE.test(field.b_field || ""))?.b_field ||
""
);
}
export function useModuleList(options = {}) {
const route = useRoute();
const pageModule = ref(null);
const dataModule = ref(null);
const activeFields = shallowRef([]);
const listConfig = shallowRef([]);
const queryItems = shallowRef([]);
const dataSource = shallowRef([]);
const appliedFilters = shallowRef([]);
const configLoading = ref(false);
const dataLoading = ref(false);
const configError = ref("");
const dataError = ref("");
const pagination = reactive({
current: 1,
pageSize: Number(unref(options.pageSize)) || 20,
total: 0,
});
let requestVersion = 0;
let dataRequestVersion = 0;
let disposed = false;
const loading = computed(() => configLoading.value || dataLoading.value);
const appliedSearchCondition = computed(() =>
appliedFilters.value.map((item) => item.condition).join(" AND "),
);
function commitAppliedFilters(filters) {
appliedFilters.value = filters.filter(Boolean);
}
async function resolvePageModule(runtimeData) {
const pageId = requireModuleId(runtimeData.b_parent_id);
const response = await loadDataApi(
"s_module",
`b_id = ${pageId} AND b_module_type = 'page' AND b_canuse = 1`,
"b_id ASC",
);
const module = response.data?.[0];
if (!module) throw new Error(`数据编码 ${runtimeData.b_code} 没有对应的启用页面`);
return module;
}
async function resolveConfiguredModules() {
const dataCode = String(unref(options.dataCode) || route.meta.dataCode || "").trim();
if (dataCode) {
const response = await loadDataApi(
"s_module",
`b_code = N'${escapeSqlText(dataCode)}' AND b_module_type = 'data' AND b_canuse = 1`,
"b_id ASC",
);
const runtimeData = response.data?.[0];
if (!runtimeData) throw new Error(`未找到编码为 ${dataCode} 的启用数据模块`);
return { runtimePage: await resolvePageModule(runtimeData), runtimeData };
}
const routePath = escapeSqlText(route.path);
const response = await loadDataApi(
"s_module",
`b_route = N'${routePath}' AND b_canuse = 1`,
"b_id ASC",
);
const runtimePage = response.data?.[0];
if (!runtimePage) throw new Error(`未找到路由 ${route.path} 对应的启用模块`);
return { runtimePage, runtimeData: await resolveDataModule(runtimePage) };
}
async function resolveDataModule(runtimePage) {
if (runtimePage.b_viewtable) return runtimePage;
const pageId = requireModuleId(runtimePage.b_id);
const response = await loadDataApi(
"s_module",
`b_parent_id = ${pageId} AND b_module_type = 'data' AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
);
const module = response.data?.find((item) => item.b_viewtable);
if (!module) throw new Error("当前页面没有配置可用的数据模块");
return module;
}
async function loadModuleConfig(version) {
configLoading.value = true;
configError.value = "";
try {
const { runtimePage, runtimeData } = await resolveConfiguredModules();
const dataId = requireModuleId(runtimeData.b_id);
const [fieldResponse, listResponse, queryResponse] = await Promise.all([
loadDataApi("s_module_field", `b_module_id = ${dataId}`, "b_id ASC"),
loadDataApi("s_module_field_list", `b_module_id = ${dataId}`, "b_xh ASC, b_id ASC"),
loadDataApi(
"s_module_field_query",
`b_module_id = ${dataId} AND b_canuse = 1`,
"b_xh ASC, b_id ASC",
),
]);
if (disposed || version !== requestVersion) return false;
const fields = (fieldResponse.data || []).filter(
(field) => field.b_canuse === 1 && IDENTIFIER_RE.test(field.b_field || ""),
);
const fieldById = new Map(fields.map((field) => [String(field.b_id), field]));
const lists = (listResponse.data || []).filter(
(item) => item.b_visible === 1 && fieldById.has(String(item.b_field_id)),
);
const queries = (queryResponse.data || [])
.map((config) => {
const field = fieldById.get(String(config.b_field_id));
if (!field) return null;
return { config, field, ...parseOptions(field) };
})
.filter(Boolean);
runtimeData.resolvedKeyField = resolveKeyField(runtimeData, fields);
pageModule.value = runtimePage;
dataModule.value = runtimeData;
activeFields.value = fields;
listConfig.value = lists;
queryItems.value = queries;
commitAppliedFilters(
queries.map((item) => createAppliedFilter(item, defaultQueryValue(item))),
);
return true;
} catch (error) {
if (disposed || version !== requestVersion) return false;
configError.value = error?.message || String(error);
throw error;
} finally {
if (version === requestVersion) configLoading.value = false;
}
}
function resolveOrderBy() {
const configured = String(dataModule.value?.b_orderfield || "").trim();
if (configured) return configured;
const keyField = dataModule.value?.resolvedKeyField;
if (keyField) return `${keyField} DESC`;
const fieldById = new Map(activeFields.value.map((field) => [String(field.b_id), field]));
const firstField = fieldById.get(String(listConfig.value[0]?.b_field_id))?.b_field;
if (IDENTIFIER_RE.test(firstField || "")) return `${firstField} ASC`;
throw new Error("数据模块未配置可用的默认排序字段");
}
async function fetchData(version = requestVersion) {
if (!dataModule.value?.b_viewtable || version !== requestVersion) return;
const dataVersion = ++dataRequestVersion;
dataLoading.value = true;
dataError.value = "";
try {
const fieldById = new Map(activeFields.value.map((field) => [String(field.b_id), field]));
const searchColumns = [
...new Set(
[
dataModule.value.resolvedKeyField,
...listConfig.value.map((item) => fieldById.get(String(item.b_field_id))?.b_field),
].filter(Boolean),
),
];
const response = await pageDataApi({
viewName: dataModule.value.b_viewtable,
orderBy: resolveOrderBy(),
searchColumns,
searchCondition: appliedSearchCondition.value,
pageNo: pagination.current,
pageSize: pagination.pageSize,
});
if (disposed || version !== requestVersion || dataVersion !== dataRequestVersion) return;
dataSource.value = response.data?.rows || [];
pagination.total = Number(response.data?.total ?? response.data?.row_count) || 0;
} catch (error) {
if (disposed || version !== requestVersion || dataVersion !== dataRequestVersion) return;
dataSource.value = [];
pagination.total = 0;
dataError.value = error?.message || String(error);
} finally {
if (version === requestVersion && dataVersion === dataRequestVersion) {
dataLoading.value = false;
}
}
}
async function initialize() {
const version = ++requestVersion;
dataRequestVersion += 1;
dataLoading.value = false;
pagination.current = 1;
dataSource.value = [];
pagination.total = 0;
try {
const loaded = await loadModuleConfig(version);
if (loaded) await fetchData(version);
} catch {
dataSource.value = [];
}
}
function search({ entries = [] }) {
commitAppliedFilters(entries.map(({ item, value }) => createAppliedFilter(item, value)));
pagination.current = 1;
return fetchData();
}
function resetSearch() {
commitAppliedFilters([]);
pagination.current = 1;
return fetchData();
}
function removeFilter(fieldName) {
commitAppliedFilters(appliedFilters.value.filter((filter) => filter.key !== fieldName));
pagination.current = 1;
return fetchData();
}
function changePage(page, pageSize) {
pagination.current = pageSize === pagination.pageSize ? page : 1;
pagination.pageSize = pageSize;
return fetchData();
}
function reload() {
return fetchData();
}
function resolveRowKey(row) {
const keyField = dataModule.value?.resolvedKeyField;
return keyField ? row?.[keyField] : JSON.stringify(row);
}
watch(
() => [String(unref(options.dataCode) || route.meta.dataCode || ""), route.path],
initialize,
{
immediate: true,
},
);
onBeforeUnmount(() => {
disposed = true;
requestVersion += 1;
dataRequestVersion += 1;
});
return {
pageModule,
dataModule,
activeFields,
listConfig,
queryItems,
dataSource,
appliedFilters,
pagination,
configLoading,
dataLoading,
loading,
configError,
dataError,
initialize,
search,
resetSearch,
removeFilter,
changePage,
reload,
resolveRowKey,
};
}
@@ -146,6 +146,16 @@ const dragRowColumn = {
resizable: false,
};
function getHeaderAlign(column) {
if (column.headerAlign) return column.headerAlign;
if (isSelectionColumn(column)) return "center";
if (column.dataIndex === "actions") return "center";
if (column.actions && column.actions.length) return "center";
if (column.checkbox === true) return "center";
if (column.editor && column.editor.type === "checkbox") return "center";
return "left";
}
const displayColumns = computed(() => {
const cols = props.columns.map((col) => resolveCustomCell(col, editingCtx));
if (props.rowDraggable) cols.unshift(dragRowColumn);
@@ -160,7 +170,10 @@ const displayColumns = computed(() => {
resizable: false,
});
}
return cols;
return cols.map((column) => ({
...column,
headerAlign: getHeaderAlign(column),
}));
});
// 剥离内部列(选择列、序号列、拖拽列)后回传,避免污染父组件 columns