20260723173317

This commit is contained in:
oneao committed 2026-07-23 17:33:18 +08:00
1 parent 59b3ccfb1c
commit 5f73b54802
33 files changed
+3064 -2108

No files matched your search

@@ -14,9 +14,7 @@ const openKeys = ref(["system"]);
const effectiveCollapsed = computed(() => appStore.sidebarCollapsed);
const menuItems = computed(() => {
const systemPages = [
{ key: "/system/metadata", label: "元数据配置", moduleCode: "m_metadata" },
{ key: "/system/i18n", label: "多语言管理", moduleCode: "m_i18n" },
{ key: "/system/authorization", label: "用户授权", moduleCode: "m_authorization" },
{ key: "/system/module-management", label: "模块管理", moduleCode: "m_metadata" },
].filter((item) => permissionStore.canModule(item.moduleCode));
const items = [{ key: "/dashboard", icon: menuIcon(House), label: "工作台" }];
if (systemPages.length)
@@ -175,7 +175,7 @@ function toggleMaximize() {
<ArrowLeft />
</button>
</a-tooltip>
<span class="tab-action__divider" />
<a-tooltip title="刷新当前页" placement="bottom">
<button
type="button"
@@ -186,7 +186,7 @@ function toggleMaximize() {
<RotateCw />
</button>
</a-tooltip>
<span class="tab-action__divider" />
<a-tooltip title="返回控制台" placement="bottom">
<button
type="button"
@@ -357,7 +357,7 @@ function toggleMaximize() {
.tab-nav-button {
display: grid;
flex: none;
width: 26px;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
@@ -386,23 +386,21 @@ function toggleMaximize() {
.tab-actions {
display: flex;
flex: none;
height: 28px;
align-items: center;
border: 1px solid var(--fms-border);
border-radius: 6px;
background: var(--fms-surface);
overflow: hidden;
gap: 4px;
}
.tab-action {
display: grid;
width: 30px;
height: 100%;
flex: none;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
border: 0;
background: transparent;
border-radius: 4px;
color: var(--fms-text-secondary);
background: var(--tabs-idle-bg);
cursor: pointer;
transition:
color 0.15s ease,
@@ -414,19 +412,8 @@ function toggleMaximize() {
}
.lucide {
width: 15px;
height: 15px;
width: 14px;
height: 14px;
}
}
.tab-action__divider {
flex: none;
width: 1px;
height: 16px;
background: var(--fms-border);
}
.tab-maximize {
margin-left: 2px;
}
</style>
+4 -26
View File
@@ -25,38 +25,16 @@ const routes = [
},
},
{
path: "system/metadata",
name: "system-metadata",
path: "system/module-management",
name: "system-module-management",
component: () => import("@/views/module-management/index.vue"),
meta: {
title: "元数据配置",
title: "模块管理",
section: "系统管理",
menuKey: "/system/metadata",
menuKey: "/system/module-management",
moduleCode: "m_metadata",
},
},
{
path: "system/authorization",
name: "system-authorization",
component: () => import("@/views/authorization-management/index.vue"),
meta: {
title: "用户授权",
section: "系统管理",
menuKey: "/system/authorization",
moduleCode: "m_authorization",
},
},
{
path: "system/i18n",
name: "system-i18n",
component: () => import("@/views/i18n-management/index.vue"),
meta: {
title: "多语言管理",
section: "系统管理",
menuKey: "/system/i18n",
moduleCode: "m_i18n",
},
},
],
},
{
+5 -4
View File
@@ -89,11 +89,12 @@ export function saveObjectApi(requests) {
}
/**
* 获取下一个雪花 ID。
* @returns {Promise<ApiResult<string>>} 取号响应,雪花 ID 使用字符串避免精度丢失。
* 获取下一个雪花 ID;传入 count 可批量获取指定数量的 ID(返回数组)。
* @param {number} [count=1] 需要获取的 ID 数量,缺省 1。
* @returns {Promise<ApiResult<string|string[]>>} count 为 1 时 data 为单个 ID 字符串,大于 1 时为 ID 字符串数组。
*/
export function nextIdApi() {
return get("/data/nextid");
export function nextIdApi(count = 1) {
return get("/data/nextid", { params: { count } });
}
/**
+5 -3
View File
@@ -25,11 +25,13 @@ export async function allocateTemporaryIds(rowGroups) {
rowGroups
.flat()
.map((row) => String(row?.b_id ?? ""))
.filter((id) => /^-\d+$/.test(id)),
.filter((id) => /^-\d+$/.test(id))
),
];
const generated = await Promise.all(temporaryIds.map(() => nextIdApi()));
const mapping = new Map(temporaryIds.map((id, index) => [id, String(generated[index].data)]));
if (!temporaryIds.length) return new Map();
const res = await nextIdApi(temporaryIds.length);
const ids = Array.isArray(res.data) ? res.data : [res.data];
const mapping = new Map(temporaryIds.map((id, index) => [id, String(ids[index])]));
rowGroups.flat().forEach((row) => {
const id = mapping.get(String(row?.b_id));
if (id) row.b_id = id;
@@ -1,300 +0,0 @@
<script setup>
import { computed, h, onMounted, ref } from "vue";
import { App, Checkbox } from "antdv-next";
import { RefreshCw, Save, ShieldCheck } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi, saveObjectApi } from "@/services/api";
import { useAuthStore } from "@/stores/auth";
import { allocateTemporaryIds, diffRows, tableChange } from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
const { message } = App.useApp();
const authStore = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const users = ref([]);
const modules = ref([]);
const powers = ref([]);
const selectedUserId = ref();
const moduleIds = ref([]);
const powerIds = ref([]);
const originalModules = ref([]);
const originalPowers = ref([]);
const userOptions = computed(() =>
users.value.map((item) => ({
value: item.b_id,
label: `${item.b_name} (${item.b_account})`,
})),
);
const moduleRows = computed(() =>
modules.value.filter((item) => item.b_module_type !== "directory"),
);
const powersByModule = computed(() => {
const result = new Map();
powers.value.forEach((power) => {
const key = String(power.b_module_id);
if (!result.has(key)) result.set(key, []);
result.get(key).push(power);
});
return result;
});
const moduleIdSet = computed(() => new Set(moduleIds.value.map(String)));
const powerIdSet = computed(() => new Set(powerIds.value.map(String)));
const columns = computed(() => [
{ dataIndex: "b_name", title: "模块", width: 220 },
{ dataIndex: "b_code", title: "模块编码", width: 160 },
{
dataIndex: "__moduleAccess",
title: "访问",
width: 80,
align: "center",
customCell: ({ row }) =>
h(Checkbox, {
checked: moduleIdSet.value.has(String(row.b_id)),
"onUpdate:checked": (checked) => toggleModule(row, checked),
}),
},
{
dataIndex: "__powers",
title: "操作权限",
width: 520,
customCell: ({ row }) =>
h(
"div",
{ class: "power-options" },
(powersByModule.value.get(String(row.b_id)) || []).map((power) =>
h(
Checkbox,
{
checked: powerIdSet.value.has(String(power.b_id)),
"onUpdate:checked": (checked) => togglePower(row, power, checked),
},
() => power.b_name,
),
),
),
},
]);
async function loadBase() {
loading.value = true;
try {
const [userResult, moduleResult, powerResult] = await Promise.all([
loadDataApi("b_user", "b_canuse = 1", "b_name ASC", [
"b_id",
"b_account",
"b_name",
"b_canuse",
]),
loadDataApi("b_module", "b_canuse = 1", "b_xh ASC"),
loadDataApi("b_module_power", "b_canuse = 1", "b_xh ASC"),
]);
users.value = userResult.data || [];
modules.value = moduleResult.data || [];
powers.value = powerResult.data || [];
if (!selectedUserId.value && users.value.length) selectedUserId.value = users.value[0].b_id;
await loadPermissions();
} catch (error) {
message.error(error.message || "授权数据加载失败");
} finally {
loading.value = false;
}
}
async function loadPermissions() {
if (!selectedUserId.value) return;
const userId = String(selectedUserId.value);
if (!/^\d+$/.test(userId)) return message.warning("用户 ID 格式不正确");
try {
const [moduleResult, powerResult] = await Promise.all([
loadDataApi("b_user_module", `b_user_id = ${userId}`, "b_id ASC"),
loadDataApi("b_user_power", `b_user_id = ${userId}`, "b_id ASC"),
]);
originalModules.value = moduleResult.data || [];
originalPowers.value = powerResult.data || [];
moduleIds.value = originalModules.value.map((item) => item.b_module_id);
powerIds.value = originalPowers.value.map((item) => item.b_power_id);
} catch (error) {
message.error(error.message || "用户权限加载失败");
}
}
function toggleModule(module, checked) {
const values = new Set(moduleIds.value.map(String));
if (checked) values.add(String(module.b_id));
else {
values.delete(String(module.b_id));
const modulePowerIds = new Set(
(powersByModule.value.get(String(module.b_id)) || []).map((item) => String(item.b_id)),
);
powerIds.value = powerIds.value.filter((value) => !modulePowerIds.has(String(value)));
}
moduleIds.value = [...values];
}
function togglePower(module, power, checked) {
if (checked && !moduleIdSet.value.has(String(module.b_id))) {
moduleIds.value = [...moduleIds.value, module.b_id];
}
const values = new Set(powerIds.value.map(String));
if (checked) values.add(String(power.b_id));
else values.delete(String(power.b_id));
powerIds.value = [...values];
}
async function save() {
if (!selectedUserId.value) return;
saving.value = true;
try {
const currentModules = moduleIds.value.map((moduleId) => {
const old = originalModules.value.find(
(item) => String(item.b_module_id) === String(moduleId),
);
return (
old || {
b_id: createTempId(),
b_user_id: selectedUserId.value,
b_module_id: moduleId,
b_inputuser_id: authStore.user.id,
b_inputdatetime: new Date().toISOString().slice(0, 19).replace("T", " "),
}
);
});
const currentPowers = powerIds.value.map((powerId) => {
const old = originalPowers.value.find((item) => String(item.b_power_id) === String(powerId));
return (
old || {
b_id: createTempId(),
b_user_id: selectedUserId.value,
b_power_id: powerId,
b_inputuser_id: authStore.user.id,
b_inputdatetime: new Date().toISOString().slice(0, 19).replace("T", " "),
}
);
});
await allocateTemporaryIds([currentModules, currentPowers]);
const moduleChanges = diffRows(currentModules, originalModules.value);
const powerChanges = diffRows(currentPowers, originalPowers.value);
await saveObjectApi([
tableChange("b_user_power", powerChanges),
tableChange("b_user_module", moduleChanges),
]);
message.success("用户授权已保存");
await loadPermissions();
} catch (error) {
message.error(error.message || "授权保存失败");
} finally {
saving.value = false;
}
}
onMounted(loadBase);
</script>
<template>
<section class="authorization-page">
<header class="page-header">
<div class="page-title">
<h1><ShieldCheck />用户授权</h1>
<span>模块访问与操作权限</span>
</div>
<div class="header-actions">
<a-button :loading="loading" @click="loadBase"><RefreshCw />刷新</a-button>
<a-button type="primary" :loading="saving" :disabled="!selectedUserId" @click="save">
<Save />保存授权
</a-button>
</div>
</header>
<div class="user-bar">
<span>用户</span>
<a-select
v-model:value="selectedUserId"
show-search
:options="userOptions"
@change="loadPermissions"
/>
</div>
<div class="table-body">
<a-spin :spinning="loading">
<FmsTable
:columns="columns"
:data-source="moduleRows"
row-key="b_id"
:col-resizable="false"
/>
</a-spin>
</div>
</section>
</template>
<style scoped lang="scss">
.authorization-page {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid var(--fms-border);
background: var(--fms-surface);
}
.page-header,
.page-title,
.page-title h1,
.header-actions,
.user-bar {
display: flex;
align-items: center;
}
.page-header {
flex: none;
justify-content: space-between;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--fms-border);
}
.page-title,
.page-title h1,
.header-actions,
.user-bar {
gap: 8px;
}
.page-title h1 {
margin: 0;
font-size: 18px;
letter-spacing: 0;
}
.page-title span {
color: var(--fms-text-secondary);
font-size: 12px;
}
.user-bar {
flex: none;
.ant-select {
width: 300px;
}
}
.table-body {
flex: 1;
min-height: 0;
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
height: 100%;
}
:deep(.power-options) {
display: flex;
align-items: center;
gap: 12px;
}
}
</style>
@@ -1,193 +0,0 @@
<script setup>
import { computed, onMounted, ref } from "vue";
import { App } from "antdv-next";
import { Languages, Plus, RefreshCw, Save, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi, saveObjectApi } from "@/services/api";
import { allocateTemporaryIds, cloneData, diffRows, tableChange } from "@/utils/dataChanges";
import { I18N_LOCALES } from "@/utils/i18n";
import { createTempId } from "@/utils/tempId";
const { message } = App.useApp();
const loading = ref(false);
const saving = ref(false);
const keyword = ref("");
const rows = ref([]);
const original = ref([]);
const activeRowKey = ref(null);
const selectedRowKeys = ref([]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
const columns = [
{ dataIndex: "b_key", title: "多语言键", width: 320, editor: { maxlength: 150 } },
{
dataIndex: "b_locale",
title: "语言",
width: 150,
editor: { type: "select", options: I18N_LOCALES },
},
{ dataIndex: "b_value", title: "翻译内容", width: 420, editor: { maxlength: 500 } },
];
const filteredRows = computed(() => {
const value = keyword.value.trim().toLowerCase();
if (!value) return rows.value;
return rows.value.filter((row) =>
`${row.b_key} ${row.b_locale} ${row.b_value}`.toLowerCase().includes(value),
);
});
async function load() {
loading.value = true;
try {
rows.value = (await loadDataApi("b_i18n", null, "b_key ASC")).data || [];
original.value = cloneData(rows.value);
selectedRowKeys.value = [];
activeRowKey.value = null;
} catch (error) {
message.error(error.message || "多语言加载失败");
} finally {
loading.value = false;
}
}
function add() {
const row = { b_id: createTempId(), b_key: "", b_locale: "en-US", b_value: "" };
rows.value.unshift(row);
activeRowKey.value = row.b_id;
}
function removeSelected() {
const selected = new Set(selectedRowKeys.value.map(String));
rows.value = rows.value.filter((row) => !selected.has(String(row.b_id)));
selectedRowKeys.value = [];
activeRowKey.value = null;
}
async function save() {
if (rows.value.some((row) => !row.b_key?.trim() || !row.b_locale || !row.b_value?.trim())) {
return message.warning("多语言键、语言和翻译内容不能为空");
}
const compound = rows.value.map((row) => `${row.b_key}\0${row.b_locale}`);
if (new Set(compound).size !== compound.length) return message.warning("多语言键和语言不能重复");
saving.value = true;
try {
await allocateTemporaryIds([rows.value]);
const changes = diffRows(rows.value, original.value);
if (!changes.inserts.length && !changes.updates.length && !changes.deletes.length) {
return message.info("没有需要保存的修改");
}
await saveObjectApi([tableChange("b_i18n", changes)]);
message.success("多语言已保存");
await load();
} catch (error) {
message.error(error.message || "保存失败");
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="i18n-page">
<header class="page-header">
<div class="page-title">
<h1><Languages />多语言管理</h1>
<span>{{ rows.length }} 条翻译</span>
</div>
<div class="header-actions">
<a-button :loading="loading" @click="load"><RefreshCw />刷新</a-button>
<a-button @click="add"><Plus />新增</a-button>
<a-button danger :disabled="!selectedRowKeys.length" @click="removeSelected">
<Trash2 />删除
</a-button>
<a-button type="primary" :loading="saving" @click="save"><Save />保存全部</a-button>
</div>
</header>
<a-input-search
v-model:value="keyword"
allow-clear
placeholder="搜索多语言键、语言或内容"
class="search"
/>
<div class="table-body">
<a-spin :spinning="loading">
<FmsTable
:columns="columns"
:data-source="filteredRows"
row-key="b_id"
:col-resizable="false"
:row-selection="rowSelection"
:active-row-key="activeRowKey"
@row-click="(_event, row) => (activeRowKey = row.b_id)"
/>
</a-spin>
</div>
</section>
</template>
<style scoped lang="scss">
.i18n-page {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
gap: 10px;
padding: 12px;
border: 1px solid var(--fms-border);
background: var(--fms-surface);
}
.page-header,
.page-title,
.page-title h1,
.header-actions {
display: flex;
align-items: center;
}
.page-header {
flex: none;
justify-content: space-between;
gap: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--fms-border);
}
.page-title,
.page-title h1,
.header-actions {
gap: 8px;
}
.page-title h1 {
margin: 0;
font-size: 18px;
letter-spacing: 0;
}
.page-title span {
color: var(--fms-text-secondary);
font-size: 12px;
}
.search {
width: 360px;
flex: none;
}
.table-body {
flex: 1;
min-height: 0;
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
height: 100%;
}
}
</style>
@@ -0,0 +1,256 @@
<script setup>
import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import FmsTree from "@/components/FmsTree.vue";
import { saveObjectApi } from "@/services/api";
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
const props = defineProps({
visible: { type: Boolean, default: false },
moduleId: { type: String, required: true },
groups: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:visible", "update"]);
const localGroups = ref([]);
const selectedGroupId = ref(null);
const editForm = ref(null);
watch(
() => props.groups,
(val) => {
localGroups.value = cloneData(val || []);
},
{ immediate: true },
);
const treeData = computed(() => {
return buildTreeData(localGroups.value);
});
function buildTreeData(items) {
const map = new Map();
const roots = [];
items.forEach((item) => {
item.key = String(item.b_id);
map.set(item.key, item);
});
items.forEach((item) => {
if (item.b_parent_id && map.has(String(item.b_parent_id))) {
const parent = map.get(String(item.b_parent_id));
if (!parent.children) parent.children = [];
parent.children.push(item);
} else {
roots.push(item);
}
});
return roots.map((r) => ({
key: r.key,
title: r.b_title || `分组-${r.b_id}`,
children: r.children?.map((c) => ({
key: c.key,
title: c.b_title || `分组-${c.b_id}`,
children: c.children?.map((gc) => ({
key: gc.key,
title: gc.b_title || `分组-${gc.b_id}`,
})),
})) || [],
}));
}
const selectedGroup = computed(() =>
localGroups.value.find((g) => String(g.b_id) === selectedGroupId.value),
);
function onTreeSelect(keys) {
if (keys.length > 0) {
selectedGroupId.value = String(keys[0]);
const g = selectedGroup.value;
editForm.value = g ? { ...g } : null;
} else {
selectedGroupId.value = null;
editForm.value = null;
}
}
function addGroup(parentId) {
const newGroup = {
b_id: createTempId(),
b_module_id: props.moduleId,
b_parent_id: parentId || null,
b_title: "",
b_i18n: null,
b_xh: localGroups.value.length * 10,
b_canuse: 1,
};
localGroups.value.push(newGroup);
selectedGroupId.value = String(newGroup.b_id);
editForm.value = { ...newGroup };
}
function deleteGroup() {
if (!selectedGroupId.value) return;
localGroups.value = localGroups.value.filter((g) => String(g.b_id) !== selectedGroupId.value);
selectedGroupId.value = null;
editForm.value = null;
}
async function saveAllGroups() {
const original = cloneData(props.groups || []);
const changes = diffRows(localGroups.value, original);
const req = tableChange("b_module_field_group", changes);
if (!hasChanges(req)) {
message.info("没有需要保存的变更");
return;
}
try {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
await saveObjectApi([req]);
emit("update", cloneData(localGroups.value));
message.success("分组已保存");
emit("update:visible", false);
} catch (e) {
message.error("保存失败:" + (e.message || e));
}
}
function onClose() {
emit("update:visible", false);
}
</script>
<template>
<a-modal
:open="visible"
title="分组管理"
width="650px"
@cancel="onClose"
@ok="saveAllGroups"
>
<div class="group-manager">
<div class="group-sidebar">
<div class="group-sidebar-header">
<span>分组树</span>
<a-button size="small" type="primary" @click="addGroup(null)">新增</a-button>
</div>
<FmsTree
v-if="treeData.length > 0"
class="group-tree"
:tree-data="treeData"
:field-names="{ title: 'title', key: 'key', children: 'children' }"
:selected-keys="selectedGroupId ? [selectedGroupId] : []"
@select="onTreeSelect"
>
<template #title="{ key, title: nodeTitle }">
<span class="group-tree-node">
<span>{{ nodeTitle }}</span>
<span class="group-tree-node-actions">
<a-button size="small" type="text" @click.stop="addGroup(key)">+</a-button>
</span>
</span>
</template>
</FmsTree>
<a-empty v-else description="暂无分组" />
</div>
<div class="group-detail" v-if="editForm">
<a-form layout="vertical">
<a-form-item label="分组标题" required>
<a-input
:value="editForm.b_title"
@update:value="(v) => editForm.b_title = v"
/>
</a-form-item>
<a-form-item label="多语言键">
<a-input
:value="editForm.b_i18n"
@update:value="(v) => editForm.b_i18n = v || null"
/>
</a-form-item>
<a-form-item label="排序号">
<a-input-number
:value="editForm.b_xh"
:min="0"
@update:value="(v) => editForm.b_xh = v"
/>
</a-form-item>
<a-form-item label="启用">
<a-switch
:checked="editForm.b_canuse === 1"
@update:checked="(v) => editForm.b_canuse = v ? 1 : 0"
/>
</a-form-item>
<a-form-item>
<a-button danger size="small" @click="deleteGroup">删除此分组</a-button>
</a-form-item>
</a-form>
</div>
<a-empty v-else description="选择左侧分组节点编辑" class="group-detail-empty" />
</div>
</a-modal>
</template>
<style scoped lang="scss">
.group-manager {
display: flex;
gap: 16px;
min-height: 350px;
}
.group-sidebar {
display: flex;
flex-direction: column;
flex: none;
width: 240px;
border: 1px solid var(--fms-border);
border-radius: 4px;
overflow: hidden;
}
.group-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
flex: none;
padding: 8px 12px;
border-bottom: 1px solid var(--fms-border);
font-size: 13px;
font-weight: 600;
}
.group-tree {
flex: 1;
min-height: 0;
overflow: auto;
padding: 4px 0;
}
.group-tree-node {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.group-tree-node-actions {
display: none;
flex: none;
}
.group-tree-node:hover .group-tree-node-actions {
display: flex;
}
.group-detail {
flex: 1;
min-width: 0;
}
.group-detail-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
</style>
@@ -1,115 +0,0 @@
<script setup>
import { computed, nextTick, ref } from "vue";
import { Plus, Trash2 } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
const props = defineProps({
title: { type: String, required: true },
columns: { type: Array, required: true },
addText: { type: String, default: "新增" },
canAdd: { type: Boolean, default: true },
canDelete: { type: Boolean, default: true },
height: { type: [String, Number], default: null },
});
const rows = defineModel("rows", { type: Array, default: () => [] });
const emit = defineEmits(["add", "remove"]);
const activeRowKey = ref(null);
const selectedRowKeys = ref([]);
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
onChange: (keys) => {
selectedRowKeys.value = keys;
},
}));
function editRow(_event, row) {
activeRowKey.value = String(row.b_id);
}
function removeSelected() {
const selected = new Set(selectedRowKeys.value.map(String));
const removed = rows.value.filter((row) => selected.has(String(row.b_id)));
rows.value = rows.value.filter((row) => !selected.has(String(row.b_id)));
if (selected.has(String(activeRowKey.value))) activeRowKey.value = null;
selectedRowKeys.value = [];
emit("remove", removed);
}
async function addRow() {
emit("add");
await nextTick();
const row = rows.value.at(-1);
if (row?.b_id != null) activeRowKey.value = String(row.b_id);
}
defineExpose({ editRow: (row) => (activeRowKey.value = String(row.b_id)) });
</script>
<template>
<section class="metadata-table-panel" :style="height ? null : { height: '100%' }">
<header class="panel-toolbar">
<strong>{{ title }}</strong>
<div class="panel-actions">
<slot name="actions" />
<a-button v-if="canAdd" type="primary" @click="addRow"> <Plus />{{ addText }} </a-button>
<a-button
v-if="canDelete"
danger
:disabled="!selectedRowKeys.length"
@click="removeSelected"
>
<Trash2 />删除
</a-button>
</div>
</header>
<div
class="table-body"
:style="
height
? { flex: 'none', height: typeof height === 'number' ? `${height}px` : height }
: null
"
>
<FmsTable
:columns="props.columns"
:col-resizable="false"
:data-source="rows"
row-key="b_id"
:row-selection="canDelete ? rowSelection : null"
:active-row-key="activeRowKey"
@row-click="editRow"
/>
</div>
</section>
</template>
<style scoped lang="scss">
.metadata-table-panel {
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
}
.panel-toolbar,
.panel-actions {
display: flex;
align-items: center;
}
.panel-toolbar {
min-height: 40px;
flex: none;
justify-content: space-between;
gap: 12px;
}
.panel-actions {
gap: 8px;
}
.table-body {
flex: 1;
min-height: 180px;
}
</style>
@@ -1,65 +1,174 @@
<script setup>
defineProps({
parentOptions: { type: Array, default: () => [] },
routeOptions: { type: Array, default: () => [] },
import { computed, ref, watch } from "vue";
const props = defineProps({
moduleId: { type: String, required: true },
data: { type: Object, default: null },
loading: { type: Boolean, default: false },
});
const module = defineModel("module", { type: Object, required: true });
function setBoolean(field, checked) {
module.value[field] = checked ? 1 : 0;
const emit = defineEmits(["update"]);
const formData = ref(null);
watch(
() => props.data,
(val) => {
formData.value = val ? { ...val } : null;
},
{ immediate: true },
);
const isDirectory = computed(() => formData.value?.b_module_type === "directory");
const isPage = computed(() => formData.value?.b_module_type === "page");
const isData = computed(() => formData.value?.b_module_type === "data");
function emitUpdate() {
if (formData.value) emit("update", { ...formData.value });
}
function setField(field, value) {
if (!formData.value) return;
formData.value[field] = value === "" ? null : value;
emitUpdate();
}
</script>
<template>
<a-form layout="vertical" class="basic-form">
<a-form-item label="模块类型">
<a-segmented
v-model:value="module.b_module_type"
:options="[
{ label: '目录', value: 'directory' },
{ label: '页面', value: 'page' },
{ label: '数据', value: 'data' },
]"
/>
</a-form-item>
<a-form-item label="模块编码"
><a-input v-model:value="module.b_code" :maxlength="50"
/></a-form-item>
<a-form-item label="默认名称"
><a-input v-model:value="module.b_name" :maxlength="100"
/></a-form-item>
<a-form-item label="多语言键"
><a-input v-model:value="module.b_i18n" :maxlength="150"
/></a-form-item>
<a-form-item label="上级模块">
<a-select v-model:value="module.b_parent_id" allow-clear :options="parentOptions" />
</a-form-item>
<a-form-item v-if="module.b_module_type === 'page'" label="已注册页面">
<a-select v-model:value="module.b_route" :options="routeOptions" />
</a-form-item>
<template v-if="module.b_module_type === 'data'">
<a-form-item label="查询视图"><a-input v-model:value="module.b_viewtable" /></a-form-item>
<a-form-item label="保存表"><a-input v-model:value="module.b_savetable" /></a-form-item>
<a-form-item label="主键字段"><a-input v-model:value="module.b_keyfield" /></a-form-item>
<a-form-item label="默认排序"><a-input v-model:value="module.b_orderfield" /></a-form-item>
</template>
<a-form-item label="同级顺序"
><a-input-number v-model:value="module.b_xh" :min="0"
/></a-form-item>
<a-form-item label="启用">
<a-switch :checked="module.b_canuse === 1" @change="setBoolean('b_canuse', $event)" />
</a-form-item>
<a-form-item v-if="module.b_module_type !== 'data'" label="显示菜单">
<a-switch :checked="module.b_canmenu === 1" @change="setBoolean('b_canmenu', $event)" />
</a-form-item>
</a-form>
<div class="basic-panel" v-if="formData">
<div class="panel-body">
<a-form layout="vertical" class="basic-form">
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="模块编码" required>
<a-input
:value="formData.b_code"
placeholder="如 m_sea"
@update:value="(v) => setField('b_code', v)"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="模块名称" required>
<a-input
:value="formData.b_name"
placeholder="模块显示名称"
@update:value="(v) => setField('b_name', v)"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="模块类型" required>
<a-select
:value="formData.b_module_type"
:options="[
{ label: '目录', value: 'directory' },
{ label: '页面', value: 'page' },
{ label: '数据', value: 'data' },
]"
@update:value="(v) => { formData.b_module_type = v; emitUpdate(); }"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="上级模块">
<a-input :value="formData.b_parent_id" disabled placeholder="通过左侧树选中后自动设置" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="多语言键">
<a-input
:value="formData.b_i18n"
placeholder="如 module.m_sea"
@update:value="(v) => setField('b_i18n', v)"
/>
</a-form-item>
</a-col>
<a-col :span="4">
<a-form-item label="排序号">
<a-input-number
:value="formData.b_xh"
:min="0"
@update:value="(v) => setField('b_xh', v)"
/>
</a-form-item>
</a-col>
</a-row>
<!-- 页面/数据 专用字段 -->
<template v-if="!isDirectory">
<a-row :gutter="16">
<a-col :span="8" v-if="isPage">
<a-form-item label="页面路由" required>
<a-input
:value="formData.b_route"
placeholder="如 /sea/list"
@update:value="(v) => setField('b_route', v)"
/>
</a-form-item>
</a-col>
<a-col :span="8" v-if="isData">
<a-form-item label="查询视图" required>
<a-input
:value="formData.b_viewtable"
placeholder="如 v_sea"
@update:value="(v) => setField('b_viewtable', v)"
/>
</a-form-item>
</a-col>
<a-col :span="8" v-if="isData">
<a-form-item label="保存表">
<a-input
:value="formData.b_savetable"
placeholder="如 d_sea"
@update:value="(v) => setField('b_savetable', v)"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16" v-if="isData">
<a-col :span="8">
<a-form-item label="主键字段">
<a-input
:value="formData.b_keyfield"
placeholder="如 b_id"
@update:value="(v) => setField('b_keyfield', v)"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="默认排序">
<a-input
:value="formData.b_orderfield"
placeholder="如 b_xh ASC"
@update:value="(v) => setField('b_orderfield', v)"
/>
</a-form-item>
</a-col>
</a-row>
</template>
</a-form>
</div>
</div>
</template>
<style scoped lang="scss">
.basic-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-body {
flex: 1;
overflow: auto;
padding: 16px 20px;
}
.basic-form {
display: grid;
max-width: 1080px;
grid-template-columns: repeat(3, minmax(180px, 1fr));
gap: 0 16px;
padding-top: 14px;
max-width: 960px;
}
</style>
@@ -0,0 +1,163 @@
<script setup>
import { computed, ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import FieldGroupManager from "./FieldGroupManager.vue";
import { createDeleteCell, createSelectCell, createToggleCell } from "./cellRenderers";
const props = defineProps({
moduleId: { type: String, required: true },
editConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update", "updateGroups"]);
const localEdit = ref([]);
const groupManagerVisible = ref(false);
watch(
() => props.editConfig,
(val) => {
localEdit.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit("update", localEdit.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === undefined || value === null ? null : value;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
const groupOptions = computed(() =>
(props.groups || [])
.filter((g) => g.b_canuse === 1)
.map((g) => ({ label: g.b_title || `分组-${g.b_id}`, value: String(g.b_id) })),
);
function addRow() {
localEdit.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_id: null,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_group_id: null,
b_xh: localEdit.value.length * 10,
});
emitUpdate();
}
function deleteRow(record) {
localEdit.value = localEdit.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
function makeSwitchCol(field, title, width) {
return {
dataIndex: field,
title,
width: width || 60,
align: "center",
customCell: createToggleCell(field, (row) => setField(row, field, row[field] === 1 ? 0 : 1)),
};
}
const columns = [
{
dataIndex: "b_field_id",
title: "字段",
width: 160,
customCell: createSelectCell({
value: "b_field_id",
getOptions: () => availableFields.value,
onChange: (row, v) => setField(row, "b_field_id", v),
placeholder: "选择字段",
}),
},
makeSwitchCol("b_required", "必填"),
makeSwitchCol("b_readonly", "只读"),
makeSwitchCol("b_disabled", "禁用"),
{
dataIndex: "b_group_id",
title: "编辑分组",
width: 160,
customCell: createSelectCell({
value: "b_group_id",
getOptions: () => groupOptions.value,
onChange: (row, v) => setField(row, "b_group_id", v),
placeholder: "选择分组",
}),
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
},
];
</script>
<template>
<div class="edit-config-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增行</a-button>
<a-button @click="groupManagerVisible = true">分组管理</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localEdit" row-key="b_id" />
</div>
<FieldGroupManager
v-model:visible="groupManagerVisible"
:module-id="moduleId"
:groups="groups"
@update="(v) => emit('updateGroups', v)"
/>
</div>
</template>
<style scoped lang="scss">
.edit-config-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -1,131 +0,0 @@
<script setup>
import { computed } from "vue";
import { createTempId } from "@/utils/tempId";
import MetadataTablePanel from "./MetadataTablePanel.vue";
const props = defineProps({ fields: { type: Array, default: () => [] } });
const groups = defineModel("groups", { type: Array, default: () => [] });
const edits = defineModel("edits", { type: Array, default: () => [] });
const fieldOptions = computed(() =>
props.fields.map((item) => ({ value: item.b_id, label: `${item.b_name} (${item.b_field})` })),
);
const groupOptions = computed(() =>
groups.value.map((item) => ({ value: item.b_code, label: item.b_title })),
);
const groupColumns = [
{ dataIndex: "b_code", title: "分组编码", width: 150, editor: { maxlength: 100 } },
{ dataIndex: "b_title", title: "默认标题", width: 180, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "标题多语言键", width: 250, editor: { maxlength: 150 } },
{ dataIndex: "b_xh", title: "顺序", width: 80, editor: { type: "number", min: 0 } },
{ dataIndex: "b_canuse", title: "启用", width: 70, align: "center", editor: "checkbox" },
];
const editColumns = computed(() => [
{
dataIndex: "b_field_id",
title: "字段",
width: 190,
editor: { type: "select", options: fieldOptions.value },
},
{
dataIndex: "b_editor",
title: "编辑器",
width: 110,
editor: {
type: "select",
options: ["input", "textarea", "select", "date", "switch"].map((value) => ({
value,
label: value,
})),
},
},
{
dataIndex: "b_edit_group",
title: "表单分组",
width: 150,
editor: { type: "select", options: groupOptions.value },
},
{ dataIndex: "b_placeholder", title: "输入提示", width: 180, editor: { maxlength: 200 } },
{ dataIndex: "b_required", title: "必填", width: 65, align: "center", editor: "checkbox" },
{ dataIndex: "b_readonly", title: "只读", width: 65, align: "center", editor: "checkbox" },
{ dataIndex: "b_disabled", title: "禁用", width: 65, align: "center", editor: "checkbox" },
{ dataIndex: "b_canform", title: "Form", width: 65, align: "center", editor: "checkbox" },
{ dataIndex: "b_edit_xh", title: "顺序", width: 75, editor: { type: "number", min: 0 } },
{
dataIndex: "b_edit_span",
title: "栅格",
width: 75,
editor: { type: "number", min: 1, max: 24 },
},
{ dataIndex: "b_editor_options", title: "编辑器 JSON", width: 260, editor: {} },
]);
function addGroup() {
groups.value.push({
b_id: createTempId(),
b_code: `group_${groups.value.length + 1}`,
b_title: "新分组",
b_i18n: null,
b_xh: Math.max(0, ...groups.value.map((item) => Number(item.b_xh) || 0)) + 10,
b_canuse: 1,
});
}
function addEdit() {
const used = new Set(edits.value.map((item) => String(item.b_field_id)));
const field = props.fields.find((item) => !used.has(String(item.b_id)));
if (!field) return;
edits.value.push({
b_id: createTempId(),
b_field_id: field.b_id,
b_editor: "input",
b_edit_format: null,
b_editor_options: null,
b_required: 0,
b_readonly: 0,
b_disabled: 0,
b_placeholder: null,
b_canform: 1,
b_edit_group: null,
b_edit_xh: Math.max(0, ...edits.value.map((item) => Number(item.b_edit_xh) || 0)) + 10,
b_edit_span: 24,
});
}
function handleGroupRemove(removed) {
const removedCodes = new Set(removed.map((item) => item.b_code));
edits.value.forEach((edit) => {
if (removedCodes.has(edit.b_edit_group)) edit.b_edit_group = null;
});
}
</script>
<template>
<div class="edit-panels">
<MetadataTablePanel
v-model:rows="groups"
title="表单分组"
add-text="新增分组"
:columns="groupColumns"
height="220px"
@add="addGroup"
@remove="handleGroupRemove"
/>
<MetadataTablePanel
v-model:rows="edits"
title="编辑字段"
add-text="新增字段"
:columns="editColumns"
@add="addEdit"
/>
</div>
</template>
<style scoped lang="scss">
.edit-panels {
display: grid;
height: 100%;
min-height: 0;
grid-template-rows: auto minmax(260px, 1fr);
gap: 12px;
}
</style>
@@ -1,52 +1,116 @@
<script setup>
import { computed } from "vue";
import { RefreshCw } from "@lucide/vue";
import MetadataTablePanel from "./MetadataTablePanel.vue";
import { ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createSelectCell, createToggleCell } from "./cellRenderers";
defineProps({ syncing: Boolean });
defineEmits(["sync"]);
const fields = defineModel("fields", { type: Array, default: () => [] });
const columns = computed(() => [
{ dataIndex: "b_field", title: "字段", width: 150 },
{ dataIndex: "b_name", title: "默认名称", width: 160, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 240, editor: { maxlength: 150 } },
{
dataIndex: "b_field_type",
title: "字段类型",
width: 130,
editor: {
type: "select",
options: [
"string",
"text",
"number",
"date",
"datetime",
"boolean",
"select_id",
"select_input",
].map((value) => ({ value, label: value })),
},
const FIELD_TYPES = [
{ label: "输入框", value: "input" },
{ label: "文本域", value: "textarea" },
{ label: "数字", value: "number" },
{ label: "金额", value: "money" },
{ label: "日期", value: "date" },
{ label: "日期时间", value: "datetime" },
{ label: "开关", value: "switch" },
{ label: "下拉选择", value: "select" },
];
const props = defineProps({
moduleId: { type: String, required: true },
fields: { type: Array, default: () => [] },
viewTable: { type: String, default: null },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update", "sync"]);
const localFields = ref([]);
watch(
() => props.fields,
(val) => {
localFields.value = val?.map((r) => ({ ...r })) || [];
},
{ dataIndex: "b_dict_code", title: "字典编码", width: 150, editor: { maxlength: 100 } },
{ dataIndex: "b_value", title: "默认值", width: 160, editor: { maxlength: 500 } },
{ dataIndex: "b_remark", title: "说明", width: 220, editor: { maxlength: 500 } },
{ dataIndex: "b_canuse", title: "启用", width: 70, align: "center", editor: "checkbox" },
]);
{ immediate: true },
);
function emitUpdate() {
emit("update", localFields.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === "" ? null : value;
emitUpdate();
}
const columns = [
{ dataIndex: "b_field", title: "字段名", width: 140 },
{ dataIndex: "b_name", title: "字段名称", width: 140 },
{
dataIndex: "b_type",
title: "控件类型",
width: 130,
customCell: createSelectCell({
value: "b_type",
getOptions: () => FIELD_TYPES,
onChange: (row, v) => setField(row, "b_type", v),
}),
},
{ dataIndex: "b_default_value", title: "默认值", width: 120 },
{ dataIndex: "b_i18n", title: "多语言键", width: 140 },
{
dataIndex: "b_canuse",
title: "启用",
width: 70,
align: "center",
customCell: createToggleCell("b_canuse", (row) => setField(row, "b_canuse", row.b_canuse === 1 ? 0 : 1)),
},
];
</script>
<template>
<MetadataTablePanel
v-model:rows="fields"
title="字段定义"
:columns="columns"
:can-add="false"
:can-delete="false"
>
<template #actions>
<a-button type="primary" :loading="syncing" @click="$emit('sync')">
<RefreshCw />从视图同步
</a-button>
</template>
</MetadataTablePanel>
<div class="fields-panel">
<div class="panel-toolbar">
<a-space>
<a-button
v-if="viewTable"
type="primary"
@click="emit('sync')"
>
同步字段 ({{ viewTable }})
</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localFields" row-key="b_id" />
</div>
</div>
</template>
<style scoped lang="scss">
.fields-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -0,0 +1,140 @@
<script setup>
import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi, saveObjectApi } from "@/services/api";
import { cloneData, diffRows, allocateTemporaryIds, tableChange, hasChanges } from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
import { createDeleteCell } from "./cellRenderers";
const props = defineProps({
moduleId: { type: String, required: true },
loading: { type: Boolean, default: false },
});
const i18nData = ref([]);
const originals = ref([]);
const isLoading = ref(false);
const isSaving = ref(false);
const searchKey = ref("");
async function loadI18n() {
isLoading.value = true;
try {
const res = await loadDataApi("b_i18n", null, "b_key ASC, b_locale ASC");
originals.value = res.data || [];
i18nData.value = cloneData(originals.value);
} catch (e) {
message.error("加载多语言失败:" + (e.message || e));
} finally {
isLoading.value = false;
}
}
watch(() => props.moduleId, loadI18n, { immediate: true });
function addRow() {
i18nData.value.push({
b_id: createTempId(),
b_key: "",
b_locale: "zh-CN",
b_value: "",
});
}
function deleteRow(record) {
i18nData.value = i18nData.value.filter((r) => r.b_id !== record.b_id);
}
async function save() {
const changes = diffRows(i18nData.value, originals.value);
const req = tableChange("b_i18n", changes);
if (!hasChanges(req)) {
message.info("没有需要保存的变更");
return;
}
isSaving.value = true;
try {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
await saveObjectApi([req]);
originals.value = cloneData(i18nData.value);
message.success("保存成功");
} catch (e) {
message.error("保存失败:" + (e.message || e));
} finally {
isSaving.value = false;
}
}
const displayedData = computed(() => {
if (!searchKey.value) return i18nData.value;
const kw = searchKey.value.toLowerCase();
return i18nData.value.filter(
(r) =>
(r.b_key || "").toLowerCase().includes(kw) ||
(r.b_value || "").toLowerCase().includes(kw),
);
});
const columns = [
{ dataIndex: "b_key", title: "键", width: 200 },
{ dataIndex: "b_locale", title: "语言", width: 100 },
{ dataIndex: "b_value", title: "翻译文本", width: 300 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
},
];
</script>
<template>
<div class="i18n-panel">
<div class="panel-toolbar">
<a-space>
<a-input-search
v-model:value="searchKey"
placeholder="搜索键或值"
style="width: 200px"
allow-clear
/>
<a-button type="primary" @click="addRow">新增翻译</a-button>
<a-button :loading="isSaving" @click="save">保存</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="displayedData" row-key="b_id" />
</div>
</div>
</template>
<style scoped lang="scss">
.i18n-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -0,0 +1,168 @@
<script setup>
import { computed, ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import FieldGroupManager from "./FieldGroupManager.vue";
import { createDeleteCell, createSelectCell, createToggleCell } from "./cellRenderers";
const props = defineProps({
moduleId: { type: String, required: true },
listConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
groups: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update", "updateGroups"]);
const localList = ref([]);
const groupManagerVisible = ref(false);
watch(
() => props.listConfig,
(val) => {
localList.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit("update", localList.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === undefined || value === null ? null : value;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
const groupOptions = computed(() =>
(props.groups || [])
.filter((g) => g.b_canuse === 1)
.map((g) => ({ label: g.b_title || `分组-${g.b_id}`, value: String(g.b_id) })),
);
function addRow() {
localList.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_id: null,
b_group_id: null,
b_title: null,
b_i18n: null,
b_visible: 1,
b_xh: localList.value.length * 10,
b_width: null,
b_sortable: 0,
});
emitUpdate();
}
function deleteRow(record) {
localList.value = localList.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
const columns = [
{
dataIndex: "b_field_id",
title: "字段",
width: 160,
customCell: createSelectCell({
value: "b_field_id",
getOptions: () => availableFields.value,
onChange: (row, v) => setField(row, "b_field_id", v),
placeholder: "选择字段",
}),
},
{ dataIndex: "b_title", title: "列标题", width: 120 },
{ dataIndex: "b_width", title: "列宽", width: 80 },
{
dataIndex: "b_group_id",
title: "分组",
width: 140,
customCell: createSelectCell({
value: "b_group_id",
getOptions: () => groupOptions.value,
onChange: (row, v) => setField(row, "b_group_id", v),
placeholder: "选择分组",
}),
},
{
dataIndex: "b_visible",
title: "显示",
width: 60,
align: "center",
customCell: createToggleCell("b_visible", (row) => setField(row, "b_visible", row.b_visible === 1 ? 0 : 1)),
},
{
dataIndex: "b_sortable",
title: "排序",
width: 60,
align: "center",
customCell: createToggleCell("b_sortable", (row) => setField(row, "b_sortable", row.b_sortable === 1 ? 0 : 1)),
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
},
];
</script>
<template>
<div class="list-config-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增列</a-button>
<a-button @click="groupManagerVisible = true">分组管理</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localList" row-key="b_id" />
</div>
<FieldGroupManager
v-model:visible="groupManagerVisible"
:module-id="moduleId"
:groups="groups"
@update="(v) => emit('updateGroups', v)"
/>
</div>
</template>
<style scoped lang="scss">
.list-config-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -1,142 +0,0 @@
<script setup>
import { computed } from "vue";
import { createTempId } from "@/utils/tempId";
import MetadataTablePanel from "./MetadataTablePanel.vue";
const props = defineProps({ fields: { type: Array, default: () => [] } });
const nodes = defineModel("nodes", { type: Array, default: () => [] });
const fieldOptions = computed(() =>
props.fields.map((item) => ({ value: item.b_id, label: `${item.b_name} (${item.b_field})` })),
);
const groupOptions = computed(() => [
{ value: null, label: "顶层" },
...nodes.value
.filter((item) => item.b_kind === "group")
.map((item) => ({ value: item.b_id, label: item.b_title || "未命名分组" })),
]);
const columns = computed(() => [
{
dataIndex: "b_kind",
title: "节点类型",
width: 100,
editor: {
type: "select",
options: [
{ value: "group", label: "分组" },
{ value: "field", label: "字段" },
],
onChange: (value, row) => {
if (value === "group") {
row.b_field_id = null;
row.b_width = null;
row.b_display_type = null;
row.b_canedit = 0;
} else {
row.b_display_type ||= "text";
row.b_width ||= 120;
}
},
},
},
{
dataIndex: "b_field_id",
title: "关联字段",
width: 190,
editor: {
type: "select",
options: fieldOptions.value,
disabled: (row) => row.b_kind === "group",
},
},
{
dataIndex: "b_parent_id",
title: "上级分组",
width: 150,
editor: {
type: "select",
options: (row) =>
groupOptions.value.filter((item) => String(item.value) !== String(row.b_id)),
},
},
{ dataIndex: "b_title", title: "分组/列标题", width: 170, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "标题多语言键", width: 230, editor: { maxlength: 150 } },
{ dataIndex: "b_visible", title: "显示", width: 65, align: "center", editor: "checkbox" },
{ dataIndex: "b_xh", title: "顺序", width: 75, editor: { type: "number", min: 0 } },
{
dataIndex: "b_width",
title: "列宽",
width: 80,
editor: { type: "number", min: 40, disabled: (row) => row.b_kind === "group" },
},
{
dataIndex: "b_display_type",
title: "显示类型",
width: 120,
editor: {
type: "select",
options: ["text", "money", "date", "datetime", "tag"].map((value) => ({
value,
label: value,
})),
disabled: (row) => row.b_kind === "group",
},
},
{
dataIndex: "b_sortable",
title: "排序",
width: 65,
align: "center",
editor: { type: "checkbox", disabled: (row) => row.b_kind === "group" },
},
{
dataIndex: "b_canedit",
title: "表内编辑",
width: 80,
align: "center",
editor: { type: "checkbox", disabled: (row) => row.b_kind === "group" },
},
]);
function addNode() {
nodes.value.push({
b_id: createTempId(),
b_parent_id: null,
b_kind: "field",
b_field_id: null,
b_title: null,
b_i18n: null,
b_visible: 1,
b_xh: Math.max(0, ...nodes.value.map((item) => Number(item.b_xh) || 0)) + 10,
b_width: 120,
b_sortable: 0,
b_display_type: "text",
b_canedit: 0,
});
}
function handleRemove(removed) {
const removedIds = new Set(removed.map((item) => String(item.b_id)));
let changed = true;
while (changed) {
changed = false;
nodes.value.forEach((node) => {
if (removedIds.has(String(node.b_parent_id)) && !removedIds.has(String(node.b_id))) {
removedIds.add(String(node.b_id));
changed = true;
}
});
}
nodes.value = nodes.value.filter((node) => !removedIds.has(String(node.b_id)));
}
</script>
<template>
<MetadataTablePanel
v-model:rows="nodes"
title="列表列与多级表头"
add-text="新增节点"
:columns="columns"
@add="addNode"
@remove="handleRemove"
/>
</template>
@@ -1,34 +1,112 @@
<script setup>
import { ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import MetadataTablePanel from "./MetadataTablePanel.vue";
import { createDeleteCell, createToggleCell } from "./cellRenderers";
const powers = defineModel("powers", { type: Array, default: () => [] });
const columns = [
{ dataIndex: "b_code", title: "操作编码", width: 150, editor: { maxlength: 50 } },
{ dataIndex: "b_name", title: "默认名称", width: 180, editor: { maxlength: 100 } },
{ dataIndex: "b_i18n", title: "多语言键", width: 260, editor: { maxlength: 150 } },
{ dataIndex: "b_xh", title: "顺序", width: 80, editor: { type: "number", min: 0 } },
{ dataIndex: "b_canuse", title: "启用", width: 70, align: "center", editor: "checkbox" },
];
const props = defineProps({
moduleId: { type: String, required: true },
powers: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
function addPower() {
powers.value.push({
const emit = defineEmits(["update"]);
const localPowers = ref([]);
watch(
() => props.powers,
(val) => {
localPowers.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit("update", localPowers.value.map((r) => ({ ...r })));
}
function addRow() {
localPowers.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_code: "",
b_name: "新操作",
b_name: "",
b_i18n: null,
b_xh: Math.max(0, ...powers.value.map((item) => Number(item.b_xh) || 0)) + 10,
b_xh: localPowers.value.length * 10,
b_canuse: 1,
});
emitUpdate();
}
function deleteRow(record) {
localPowers.value = localPowers.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
const columns = [
{ dataIndex: "b_code", title: "操作编码", width: 160 },
{ dataIndex: "b_name", title: "操作名称", width: 160 },
{ dataIndex: "b_i18n", title: "多语言键", width: 160 },
{ dataIndex: "b_xh", title: "排序", width: 80 },
{
dataIndex: "b_canuse",
title: "启用",
width: 70,
align: "center",
customCell: createToggleCell("b_canuse", (row) => {
row.b_canuse = row.b_canuse === 1 ? 0 : 1;
emitUpdate();
}),
},
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
},
];
</script>
<template>
<MetadataTablePanel
v-model:rows="powers"
title="模块操作权限"
add-text="新增操作"
:columns="columns"
@add="addPower"
/>
<div class="powers-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增权限</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localPowers" row-key="b_id" />
</div>
</div>
</template>
<style scoped lang="scss">
.powers-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -0,0 +1,165 @@
<script setup>
import { computed, ref, watch } from "vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
import { createDeleteCell, createSelectCell, createToggleCell } from "./cellRenderers";
const COMPONENT_OPTIONS = [
{ label: "输入框", value: "input" },
{ label: "下拉选择", value: "select" },
{ label: "日期选择", value: "date-picker" },
{ label: "数字范围", value: "number-range" },
];
const OPERATOR_OPTIONS = [
{ label: "等于 (=)", value: "=" },
{ label: "模糊 (like)", value: "like" },
{ label: "范围 (between)", value: "between" },
{ label: "包含 (in)", value: "in" },
];
const props = defineProps({
moduleId: { type: String, required: true },
queryConfig: { type: Array, default: () => [] },
fields: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(["update"]);
const localQuery = ref([]);
watch(
() => props.queryConfig,
(val) => {
localQuery.value = val?.map((r) => ({ ...r })) || [];
},
{ immediate: true },
);
function emitUpdate() {
emit("update", localQuery.value.map((r) => ({ ...r })));
}
function setField(row, field, value) {
row[field] = value === undefined || value === null ? null : value;
emitUpdate();
}
const availableFields = computed(() =>
(props.fields || [])
.filter((f) => f.b_canuse === 1)
.map((f) => ({ label: f.b_name || f.b_field, value: String(f.b_id) })),
);
function addRow() {
localQuery.value.push({
b_id: createTempId(),
b_module_id: props.moduleId,
b_field_id: null,
b_component: "input",
b_operator: "=",
b_default_value: null,
b_xh: localQuery.value.length * 10,
b_canuse: 1,
});
emitUpdate();
}
function deleteRow(record) {
localQuery.value = localQuery.value.filter((r) => r.b_id !== record.b_id);
emitUpdate();
}
const columns = [
{
dataIndex: "b_field_id",
title: "字段",
width: 160,
customCell: createSelectCell({
value: "b_field_id",
getOptions: () => availableFields.value,
onChange: (row, v) => setField(row, "b_field_id", v),
placeholder: "选择字段",
}),
},
{
dataIndex: "b_component",
title: "查询组件",
width: 140,
customCell: createSelectCell({
value: "b_component",
getOptions: () => COMPONENT_OPTIONS,
onChange: (row, v) => setField(row, "b_component", v),
}),
},
{
dataIndex: "b_operator",
title: "操作符",
width: 140,
customCell: createSelectCell({
value: "b_operator",
getOptions: () => OPERATOR_OPTIONS,
onChange: (row, v) => setField(row, "b_operator", v),
}),
},
{ dataIndex: "b_default_value", title: "默认查询值", width: 140 },
{
dataIndex: "b_canuse",
title: "启用",
width: 60,
align: "center",
customCell: createToggleCell("b_canuse", (row) => setField(row, "b_canuse", row.b_canuse === 1 ? 0 : 1)),
},
{ dataIndex: "b_xh", title: "排序号", width: 80 },
{
dataIndex: "actions",
title: "操作",
width: 60,
align: "center",
customCell: createDeleteCell((row) => deleteRow(row)),
},
];
</script>
<template>
<div class="query-config-panel">
<div class="panel-toolbar">
<a-space>
<a-button type="primary" @click="addRow">新增查询条件</a-button>
</a-space>
</div>
<div class="panel-body">
<FmsTable :columns="columns" :data-source="localQuery" row-key="b_id" />
</div>
</div>
</template>
<style scoped lang="scss">
.query-config-panel {
display: flex;
flex-direction: column;
height: 100%;
}
.panel-toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 12px 20px;
}
.panel-body {
flex: 1;
min-height: 0;
padding: 16px 20px;
display: flex;
flex-direction: column;
}
.panel-body > :deep(.fms-table) {
flex: 1;
min-height: 0;
width: 100%;
}
</style>
@@ -1,65 +0,0 @@
<script setup>
import { computed } from "vue";
import { createTempId } from "@/utils/tempId";
import MetadataTablePanel from "./MetadataTablePanel.vue";
const props = defineProps({ fields: { type: Array, default: () => [] } });
const queries = defineModel("queries", { type: Array, default: () => [] });
const fieldOptions = computed(() =>
props.fields.map((item) => ({ value: item.b_id, label: `${item.b_name} (${item.b_field})` })),
);
const columns = computed(() => [
{
dataIndex: "b_field_id",
title: "字段",
width: 210,
editor: { type: "select", options: fieldOptions.value },
},
{
dataIndex: "b_component",
title: "查询组件",
width: 130,
editor: {
type: "select",
options: ["input", "select", "date", "date-range"].map((value) => ({ value, label: value })),
},
},
{
dataIndex: "b_operator",
title: "操作符",
width: 100,
editor: {
type: "select",
options: ["=", "like", "between", "in"].map((value) => ({ value, label: value })),
},
},
{ dataIndex: "b_default_value", title: "默认值", width: 220, editor: { maxlength: 500 } },
{ dataIndex: "b_xh", title: "顺序", width: 80, editor: { type: "number", min: 0 } },
{ dataIndex: "b_canuse", title: "启用", width: 70, align: "center", editor: "checkbox" },
]);
function addQuery() {
const used = new Set(queries.value.map((item) => String(item.b_field_id)));
const field = props.fields.find((item) => !used.has(String(item.b_id)));
if (!field) return;
queries.value.push({
b_id: createTempId(),
b_field_id: field.b_id,
b_component: "input",
b_operator: "like",
b_default_value: null,
b_xh: Math.max(0, ...queries.value.map((item) => Number(item.b_xh) || 0)) + 10,
b_canuse: 1,
});
}
</script>
<template>
<MetadataTablePanel
v-model:rows="queries"
title="列表查询条件"
add-text="新增条件"
:columns="columns"
@add="addQuery"
/>
</template>
@@ -1,44 +0,0 @@
<script setup>
import { computed } from "vue";
import { createTempId } from "@/utils/tempId";
import { I18N_LOCALES } from "@/utils/i18n";
import MetadataTablePanel from "./MetadataTablePanel.vue";
const props = defineProps({ keys: { type: Array, default: () => [] } });
const translations = defineModel("translations", { type: Array, default: () => [] });
const columns = computed(() => [
{
dataIndex: "b_key",
title: "多语言键",
width: 300,
editor: { type: "select", options: props.keys.map((value) => ({ value, label: value })) },
},
{
dataIndex: "b_locale",
title: "语言",
width: 150,
editor: { type: "select", options: I18N_LOCALES },
},
{ dataIndex: "b_value", title: "翻译内容", width: 380, editor: { maxlength: 500 } },
]);
function addTranslation() {
translations.value.push({
b_id: createTempId(),
b_key: props.keys[0] || "",
b_locale: "en-US",
b_value: "",
});
}
</script>
<template>
<MetadataTablePanel
v-model:rows="translations"
title="模块关联翻译"
add-text="新增翻译"
:columns="columns"
:can-delete="false"
@add="addTranslation"
/>
</template>
@@ -1,164 +0,0 @@
<script setup>
import { computed, ref } from "vue";
import { RefreshCw, Search } from "@lucide/vue";
import FmsTree from "@/components/FmsTree.vue";
const props = defineProps({
modules: { type: Array, required: true },
protectedKeys: { type: Array, default: () => [] },
selectedKey: { type: String, default: "" },
loading: Boolean,
canCreate: Boolean,
canDelete: Boolean,
});
const emit = defineEmits(["select", "add", "delete", "refresh"]);
const searchText = ref("");
const contextNode = ref(null);
const treeData = computed(() => {
const keyword = searchText.value.trim().toLowerCase();
const codeById = new Map(props.modules.map((item) => [item.b_id, item.b_code]));
const visible = keyword
? new Set(
props.modules
.filter(
(item) =>
String(item.b_code).toLowerCase().includes(keyword) ||
String(item.b_name).toLowerCase().includes(keyword),
)
.flatMap((item) => [item.b_code, codeById.get(item.b_parent_id)].filter(Boolean)),
)
: null;
const children = new Map();
props.modules.forEach((item) => {
const parent = item.b_parent_id || "__root__";
if (!children.has(parent)) children.set(parent, []);
children.get(parent).push(item);
});
const build = (parent) =>
(children.get(parent) || [])
.filter((item) => !visible || visible.has(item.b_code))
.map((item) => {
const nodeChildren = build(item.b_id);
return {
key: item.b_code,
title: item.b_name,
children: nodeChildren,
isLeaf: nodeChildren.length === 0,
};
});
return build("__root__");
});
function onSelect(keys) {
if (keys[0]) emit("select", keys[0]);
}
function onRightClick({ node }) {
contextNode.value = node;
emit("select", node.key);
}
const contextMenu = computed(() => {
const node = contextNode.value;
return {
items: [
{ key: "directory", label: "新增目录", disabled: !props.canCreate },
{ key: "page", label: "新增页面", disabled: !props.canCreate },
{ key: "data", label: "新增数据模块", disabled: !props.canCreate },
{ type: "divider" },
{
key: "delete",
label: "删除",
danger: true,
disabled: !node || !props.canDelete || props.protectedKeys.includes(String(node.key)),
},
],
onClick: ({ key }) => {
if (!node) return;
if (key === "delete") {
emit("delete", node.key);
return;
}
emit("add", key, node.key);
},
};
});
</script>
<template>
<aside class="module-tree-panel">
<header>
<div>
<strong>模块</strong>
<span>{{ modules.length }}</span>
</div>
<a-tooltip title="刷新">
<a-button type="text" size="small" :loading="loading" @click="emit('refresh')">
<RefreshCw :size="16" />
</a-button>
</a-tooltip>
</header>
<a-input v-model:value="searchText" allow-clear placeholder="搜索名称或编码">
<template #prefix><Search :size="15" /></template>
</a-input>
<a-spin :spinning="loading" class="tree-spin">
<a-dropdown :menu="contextMenu" :trigger="['contextmenu']">
<FmsTree
block-node
default-expand-all
:tree-data="treeData"
:selected-keys="selectedKey ? [selectedKey] : []"
@right-click="onRightClick"
@select="onSelect"
/>
</a-dropdown>
</a-spin>
</aside>
</template>
<style scoped>
.module-tree-panel {
display: flex;
min-height: 0;
flex-direction: column;
gap: 8px;
border-right: 1px solid #e5e7eb;
background: #fff;
padding: 10px 8px;
}
header,
header > div {
display: flex;
align-items: center;
}
header {
justify-content: space-between;
}
header > div {
gap: 8px;
}
header span {
color: #8c8c8c;
font-size: 12px;
}
.tree-spin {
min-height: 0;
flex: 1 1 auto;
overflow: auto;
}
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
min-height: 0;
}
</style>
@@ -0,0 +1,64 @@
import { h } from "vue";
import { Button, Popconfirm, Select } from "antdv-next";
// 单元格渲染器统一采用 stk-table-vue 的 customCell 写法:
// customCell 会被当作函数式组件调用(props 含 row/col/rowIndex/cellValue),
// 必须返回 h(...) 生成的 VNode,返回普通对象会渲染成 [object Object]。
// 删除操作单元格
export function createDeleteCell(onDelete) {
return (props) =>
h(
Popconfirm,
{
title: "确定删除该记录?",
okText: "删除",
okButtonProps: { danger: true },
onConfirm: () => onDelete(props.row),
},
{
default: () =>
h(
Button,
{ type: "link", size: "small", danger: true, onClick: (e) => e.stopPropagation() },
"删除",
),
},
);
}
// 是否(0/1)切换单元格
export function createToggleCell(field, onChange) {
return (props) => {
const checked = props.row[field] === 1;
return h(
"span",
{
style: { cursor: "pointer" },
onClick: (e) => {
e.stopPropagation();
onChange(props.row, field);
},
},
checked ? "是" : "否",
);
};
}
// 下拉选择单元格。getOptions 返回最新选项(保持响应式)。
export function createSelectCell({ value, getOptions, onChange, placeholder, allowClear }) {
return (props) =>
h(Select, {
value:
props.row[value] != null && props.row[value] !== ""
? String(props.row[value])
: undefined,
size: "small",
style: { width: "100%" },
options: getOptions(),
allowClear: allowClear !== false,
placeholder: placeholder || "请选择",
"onUpdate:value": (v) => onChange(props.row, v),
onClick: (e) => e.stopPropagation(),
});
}
File diff suppressed because it is too large. Load diff