20260720215810

This commit is contained in:
oneao committed 2026-07-20 21:58:10 +08:00
1 parent 94207c77ff
commit fd161dd302
9 files changed
+1242 -1102

No files matched your search

@@ -0,0 +1,143 @@
<script setup>
const props = defineProps({
directoryParentOptions: { type: Array, default: () => [] },
isNew: Boolean,
parentOptions: { type: Array, default: () => [] },
});
const emit = defineEmits(["update-module-id"]);
const module = defineModel("module", { type: Object, default: null });
function updateRoute(value) {
module.value.b_route = value || null;
if (!String(value || "").trim()) module.value.b_canmenu = 0;
}
</script>
<template>
<section v-if="module" class="module-basic-info">
<a-form v-if="module.b_type === 'directory'" layout="vertical" class="directory-form">
<div class="directory-grid">
<a-form-item label="模块编码" required>
<a-input :value="module.b_id" disabled />
</a-form-item>
<a-form-item label="模块名称" required>
<a-input v-model:value="module.b_name" />
</a-form-item>
<a-form-item label="上级模块">
<a-select
v-model:value="module.b_parent_id"
allow-clear
show-search
option-filter-prop="label"
:options="directoryParentOptions.filter((item) => item.value !== module.b_id)"
/>
</a-form-item>
<a-form-item label="多语言键">
<a-input v-model:value="module.b_i18n" />
</a-form-item>
<a-form-item label="显示顺序">
<a-input-number v-model:value="module.b_xh" :min="0" />
</a-form-item>
</div>
</a-form>
<a-form v-else layout="vertical" class="basic-form">
<div class="form-grid">
<a-form-item label="模块编码" required>
<a-input
:value="module.b_id"
:disabled="!isNew"
@update:value="emit('update-module-id', $event)"
/>
</a-form-item>
<a-form-item label="模块名称" required>
<a-input v-model:value="module.b_name" />
</a-form-item>
<a-form-item label="上级模块">
<a-select
v-model:value="module.b_parent_id"
allow-clear
show-search
option-filter-prop="label"
:options="parentOptions"
/>
</a-form-item>
<a-form-item label="页面路由(可选)">
<a-input
:value="module.b_route"
placeholder="留空时作为数据模块使用"
@update:value="updateRoute"
/>
</a-form-item>
<a-form-item label="多语言键">
<a-input v-model:value="module.b_i18n" />
</a-form-item>
<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>
<a-form-item label="排序方向">
<a-segmented v-model:value="module.b_ordertype" :options="['ASC', 'DESC']" />
</a-form-item>
<a-form-item label="显示顺序">
<a-input-number v-model:value="module.b_xh" :min="0" />
</a-form-item>
</div>
</a-form>
</section>
</template>
<style scoped>
.module-basic-info {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding-top: 10px;
}
.directory-form {
max-width: 760px;
}
.basic-form {
max-width: 1040px;
padding-top: 2px;
}
.directory-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 10px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 10px;
}
:deep(.ant-form-item) {
margin-bottom: 8px;
}
@media (max-width: 980px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.directory-grid,
.form-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,248 @@
<script setup>
import { computed, ref } from "vue";
import { Languages, Plus, Trash2 } from "@lucide/vue";
import { LOCALES } from "../../../services/moduleManagement";
import AutoHeightTable from "../../../components/AutoHeightTable.vue";
defineProps({
canSave: Boolean,
});
const emit = defineEmits(["add"]);
const fields = defineModel("fields", { type: Array, default: () => [] });
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const editingFieldId = ref("");
const selectedFieldKeys = ref([]);
const columns = [
{ title: "字段", key: "b_field", width: 150 },
{ title: "名称", key: "b_name", width: 150 },
{ title: "类型", key: "b_type", width: 120 },
{ title: "列表", key: "b_canlist", width: 64, align: "center" },
{ title: "表单", key: "b_canform", width: 64, align: "center" },
{ title: "必填", key: "b_must", width: 64, align: "center" },
{ title: "查询", key: "b_canquery", width: 64, align: "center" },
{ title: "只读", key: "b_readonly", width: 64, align: "center" },
{ title: "宽度", key: "b_width", width: 90 },
{ title: "顺序", key: "b_xh", width: 80 },
{ title: "操作", key: "operation", width: 58, fixed: "right" },
];
const fieldTypes = [
["string", "单行文本"],
["text", "多行文本"],
["number", "数字"],
["select", "选择"],
["date", "日期"],
["boolean", "布尔"],
].map(([value, label]) => ({ value, label }));
const rowSelection = computed(() => ({
selectedRowKeys: selectedFieldKeys.value,
onChange: (keys) => {
selectedFieldKeys.value = keys;
},
columnWidth: 40,
}));
function translationEntry(key) {
translations.value[key] ||= {};
return translations.value[key];
}
function setFieldTranslation(record, locale, value) {
translationEntry(record.b_i18n)[locale] = value;
if (locale === "zh-CN") record.b_name = value;
}
function fieldTranslationValue(record, locale) {
return translationEntry(record.b_i18n)[locale] ?? (locale === "zh-CN" ? record.b_name : "");
}
function fieldTypeLabel(value) {
return fieldTypes.find((item) => item.value === value)?.label || value;
}
function setBoolean(record, field, checked) {
record[field] = checked ? 1 : 0;
if (field === "b_canform" && !checked) record.b_must = 0;
}
function fieldRow(record) {
return {
onClick: () => {
editingFieldId.value = String(record.b_id);
},
};
}
function leaveEdit(event) {
if (!event.target.closest(".ant-table-row")) editingFieldId.value = "";
}
function removeField(record) {
fields.value = fields.value.filter((item) => String(item.b_id) !== String(record.b_id));
selectedFieldKeys.value = selectedFieldKeys.value.filter(
(key) => String(key) !== String(record.b_id),
);
}
function deleteSelectedFields() {
const ids = new Set(selectedFieldKeys.value.map(String));
fields.value = fields.value.filter((item) => !ids.has(String(item.b_id)));
selectedFieldKeys.value = [];
}
</script>
<template>
<section class="table-pane" @click="leaveEdit">
<div class="table-toolbar">
<a-button type="primary" ghost :disabled="!canSave" @click="emit('add')">
<Plus :size="15" />新增字段
</a-button>
<a-popconfirm title="确定删除选中的字段吗?" @confirm="deleteSelectedFields">
<a-button danger :disabled="!canSave || !selectedFieldKeys.length">
<Trash2 :size="15" />批量删除 ({{ selectedFieldKeys.length }})
</a-button>
</a-popconfirm>
</div>
<AutoHeightTable
:columns="columns"
:data-source="fields"
:pagination="false"
:row-class-name="(record) => (String(record.b_id) === editingFieldId ? 'editing-row' : '')"
:row-selection="rowSelection"
row-key="b_id"
@row="fieldRow"
>
<template #bodyCell="{ column, record }">
<a-tooltip v-if="column.key === 'operation'" title="删除字段">
<a-button type="text" danger @click.stop="removeField(record)">
<Trash2 :size="15" />
</a-button>
</a-tooltip>
<template v-else-if="String(record.b_id) === editingFieldId">
<a-input v-if="column.key === 'b_field'" v-model:value="record.b_field" />
<div v-else-if="column.key === 'b_name'" class="field-name-editor">
<a-input
:value="record.b_name"
@update:value="setFieldTranslation(record, 'zh-CN', $event)"
/>
<a-popover trigger="click" placement="bottomRight">
<template #content>
<a-space direction="vertical" size="small" style="width: 260px">
<div v-for="locale in LOCALES" :key="locale.value">
<div class="locale-label">{{ locale.label }}</div>
<a-input
:value="fieldTranslationValue(record, locale.value)"
@update:value="setFieldTranslation(record, locale.value, $event)"
/>
</div>
</a-space>
</template>
<a-button type="text" class="i18n-button" @click.stop>
<Languages :size="15" />
</a-button>
</a-popover>
</div>
<a-select
v-else-if="column.key === 'b_type'"
v-model:value="record.b_type"
:options="fieldTypes"
/>
<a-switch
v-else-if="
['b_canlist', 'b_canform', 'b_must', 'b_canquery', 'b_readonly'].includes(column.key)
"
size="small"
:checked="record[column.key] === 1"
:disabled="column.key === 'b_must' && record.b_canform === 0"
@change="setBoolean(record, column.key, $event)"
/>
<a-input-number
v-else-if="column.key === 'b_width'"
v-model:value="record.b_width"
:min="40"
/>
<a-input-number v-else-if="column.key === 'b_xh'" v-model:value="record.b_xh" :min="0" />
</template>
<template v-else>
<span v-if="column.key === 'b_field'" class="cell-code">{{ record.b_field }}</span>
<span v-else-if="column.key === 'b_name'" class="cell-name">
{{ record.b_name }}<Languages v-if="record.b_i18n" :size="13" />
</span>
<span v-else-if="column.key === 'b_type'">{{ fieldTypeLabel(record.b_type) }}</span>
<a-switch
v-else-if="
['b_canlist', 'b_canform', 'b_must', 'b_canquery', 'b_readonly'].includes(column.key)
"
size="small"
:checked="record[column.key] === 1"
disabled
/>
<span v-else-if="column.key === 'b_width'">{{ record.b_width || "-" }}</span>
<span v-else-if="column.key === 'b_xh'">{{ record.b_xh }}</span>
</template>
</template>
</AutoHeightTable>
</section>
</template>
<style scoped>
.table-pane {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.table-toolbar {
display: flex;
min-height: 40px;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.field-name-editor,
.cell-name {
display: flex;
align-items: center;
gap: 4px;
}
.field-name-editor .ant-input {
min-width: 0;
}
.i18n-button {
flex: 0 0 auto;
padding-inline: 5px;
}
.cell-name svg {
flex: 0 0 auto;
color: #8c8c8c;
}
.cell-code {
color: #475569;
font-family: Consolas, monospace;
font-size: 12px;
}
:deep(.editing-row > td) {
background: #f0f7ff !important;
}
:deep(.ant-table-row) {
cursor: pointer;
}
:deep(.ant-table-cell .ant-input-number),
:deep(.ant-table-cell .ant-select) {
width: 100%;
}
</style>
@@ -0,0 +1,156 @@
<script setup>
import { computed, ref } from "vue";
import { Plus, Trash2 } from "@lucide/vue";
import AutoHeightTable from "../../../components/AutoHeightTable.vue";
defineProps({
canSave: Boolean,
});
const emit = defineEmits(["add"]);
const powers = defineModel("powers", { type: Array, default: () => [] });
const editingPowerId = ref("");
const selectedPowerKeys = ref([]);
const columns = [
{ title: "权限代码", key: "b_code", width: 180 },
{ title: "名称", key: "b_name", width: 200 },
{ title: "多语言键", key: "b_i18n" },
{ title: "启用", key: "b_canuse", width: 74, align: "center" },
{ title: "顺序", key: "b_xh", width: 90 },
{ title: "操作", key: "operation", width: 58 },
];
const rowSelection = computed(() => ({
selectedRowKeys: selectedPowerKeys.value,
onChange: (keys) => {
selectedPowerKeys.value = keys;
},
columnWidth: 40,
}));
function setBoolean(record, field, checked) {
record[field] = checked ? 1 : 0;
}
function powerRow(record) {
return {
onClick: () => {
editingPowerId.value = String(record.b_id);
},
};
}
function leaveEdit(event) {
if (!event.target.closest(".ant-table-row")) editingPowerId.value = "";
}
function removePower(record) {
powers.value = powers.value.filter((item) => String(item.b_id) !== String(record.b_id));
selectedPowerKeys.value = selectedPowerKeys.value.filter(
(key) => String(key) !== String(record.b_id),
);
}
function deleteSelectedPowers() {
const ids = new Set(selectedPowerKeys.value.map(String));
powers.value = powers.value.filter((item) => !ids.has(String(item.b_id)));
selectedPowerKeys.value = [];
}
</script>
<template>
<section class="table-pane" @click="leaveEdit">
<div class="table-toolbar">
<a-button type="primary" ghost :disabled="!canSave" @click="emit('add')">
<Plus :size="15" />新增权限
</a-button>
<a-popconfirm title="确定删除选中的权限吗?" @confirm="deleteSelectedPowers">
<a-button danger :disabled="!canSave || !selectedPowerKeys.length">
<Trash2 :size="15" />批量删除 ({{ selectedPowerKeys.length }})
</a-button>
</a-popconfirm>
</div>
<AutoHeightTable
:columns="columns"
:data-source="powers"
:pagination="false"
:row-class-name="(record) => (String(record.b_id) === editingPowerId ? 'editing-row' : '')"
:row-selection="rowSelection"
row-key="b_id"
@row="powerRow"
>
<template #bodyCell="{ column, record }">
<a-tooltip v-if="column.key === 'operation'" title="删除权限">
<a-button type="text" danger @click.stop="removePower(record)">
<Trash2 :size="15" />
</a-button>
</a-tooltip>
<template v-else-if="String(record.b_id) === editingPowerId">
<a-input v-if="column.key === 'b_code'" v-model:value="record.b_code" />
<a-input v-else-if="column.key === 'b_name'" v-model:value="record.b_name" />
<a-input v-else-if="column.key === 'b_i18n'" v-model:value="record.b_i18n" />
<a-switch
v-else-if="column.key === 'b_canuse'"
size="small"
:checked="record.b_canuse === 1"
@change="setBoolean(record, 'b_canuse', $event)"
/>
<a-input-number v-else-if="column.key === 'b_xh'" v-model:value="record.b_xh" :min="0" />
</template>
<template v-else>
<span v-if="column.key === 'b_code'" class="cell-code">{{ record.b_code }}</span>
<span v-else-if="column.key === 'b_name'">{{ record.b_name }}</span>
<span v-else-if="column.key === 'b_i18n'" class="cell-code">
{{ record.b_i18n || "-" }}
</span>
<a-switch
v-else-if="column.key === 'b_canuse'"
size="small"
:checked="record.b_canuse === 1"
disabled
/>
<span v-else-if="column.key === 'b_xh'">{{ record.b_xh }}</span>
</template>
</template>
</AutoHeightTable>
</section>
</template>
<style scoped>
.table-pane {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.table-toolbar {
display: flex;
min-height: 40px;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.cell-code {
color: #475569;
font-family: Consolas, monospace;
font-size: 12px;
}
:deep(.editing-row > td) {
background: #f0f7ff !important;
}
:deep(.ant-table-row) {
cursor: pointer;
}
:deep(.ant-table-cell .ant-input-number) {
width: 100%;
}
</style>
@@ -0,0 +1,140 @@
<script setup>
import { computed, ref } from "vue";
import { Plus, Trash2 } from "@lucide/vue";
import { App } from "antdv-next";
import { LOCALES } from "../../../services/moduleManagement";
import AutoHeightTable from "../../../components/AutoHeightTable.vue";
const props = defineProps({
canSave: Boolean,
keys: { type: Array, default: () => [] },
});
const customKeys = defineModel("customKeys", { type: Array, default: () => [] });
const translations = defineModel("translations", { type: Object, default: () => ({}) });
const { message } = App.useApp();
const newI18nKey = ref("");
const showI18nInput = ref(false);
const columns = [
{ title: "多语言键", key: "key", width: 280, fixed: "left" },
...LOCALES.map((locale) => ({
title: locale.label,
key: locale.value,
width: 220,
})),
{ title: "操作", key: "operation", width: 58, fixed: "right" },
];
const rows = computed(() =>
props.keys.map((key) => ({
key,
isCustom: customKeys.value.includes(key),
})),
);
function translationEntry(key) {
translations.value[key] ||= {};
return translations.value[key];
}
function setTranslation(key, locale, value) {
translationEntry(key)[locale] = value;
}
function addCustomKey() {
const key = newI18nKey.value.trim();
if (!key) return;
if (props.keys.includes(key)) {
message.warning("多语言键已存在");
return;
}
customKeys.value = [...customKeys.value, key];
translationEntry(key);
newI18nKey.value = "";
showI18nInput.value = false;
}
function removeCustomKey(key) {
customKeys.value = customKeys.value.filter((item) => item !== key);
delete translations.value[key];
}
</script>
<template>
<section class="translation-pane">
<div class="table-toolbar">
<template v-if="showI18nInput">
<a-input
v-model:value="newI18nKey"
placeholder="输入多语言键名,回车确认"
size="small"
class="new-key-input"
@press-enter="addCustomKey"
/>
<a-button
type="primary"
ghost
size="small"
:disabled="!newI18nKey.trim()"
@click="addCustomKey"
>
<Plus :size="15" />添加
</a-button>
<a-button size="small" @click="showI18nInput = false">取消</a-button>
</template>
<a-button v-else type="primary" ghost :disabled="!canSave" @click="showI18nInput = true">
<Plus :size="15" />新增多语言键
</a-button>
</div>
<AutoHeightTable :columns="columns" :data-source="rows" :pagination="false" row-key="key">
<template #bodyCell="{ column, record }">
<code v-if="column.key === 'key'" class="translation-key">{{ record.key }}</code>
<a-input
v-else-if="LOCALES.some((locale) => locale.value === column.key)"
:value="translationEntry(record.key)[column.key]"
size="small"
:placeholder="column.key === 'zh-CN' ? '必填' : '可选'"
@update:value="setTranslation(record.key, column.key, $event)"
/>
<a-tooltip v-else-if="column.key === 'operation' && record.isCustom" title="删除多语言键">
<a-button type="text" danger @click="removeCustomKey(record.key)">
<Trash2 :size="15" />
</a-button>
</a-tooltip>
</template>
<template #emptyText>
<a-empty description="当前模块没有多语言键" />
</template>
</AutoHeightTable>
</section>
</template>
<style scoped>
.translation-pane {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.table-toolbar {
display: flex;
min-height: 40px;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.new-key-input {
width: 260px;
}
.translation-key {
color: #374151;
font-family: Consolas, monospace;
font-size: 12px;
}
</style>
@@ -1,22 +1,17 @@
<script setup>
import { computed, ref } from "vue";
import { FileText, Folder, Plus, RefreshCw, Search } from "@lucide/vue";
import { RefreshCw, Search } from "@lucide/vue";
const props = defineProps({
modules: { type: Array, required: true },
selectedKey: { type: String, default: "" },
loading: Boolean,
canCreate: Boolean,
canDelete: Boolean,
});
const emit = defineEmits(["select", "add", "refresh"]);
const emit = defineEmits(["select", "add", "delete", "refresh"]);
const searchText = ref("");
const addMenu = {
items: [
{ key: "module", label: "新增模块" },
{ key: "directory", label: "新增目录" },
],
onClick: ({ key }) => emit("add", key),
};
const contextNode = ref(null);
const treeData = computed(() => {
const keyword = searchText.value.trim().toLowerCase();
@@ -25,8 +20,8 @@ const treeData = computed(() => {
props.modules
.filter(
(item) =>
item.b_id.toLowerCase().includes(keyword) ||
item.b_name.toLowerCase().includes(keyword),
String(item.b_id).toLowerCase().includes(keyword) ||
String(item.b_name).toLowerCase().includes(keyword),
)
.flatMap((item) => [item.b_id, item.b_parent_id]),
)
@@ -43,16 +38,46 @@ const treeData = computed(() => {
.map((item) => ({
key: item.b_id,
title: item.b_name,
code: item.b_id,
type: item.b_type,
children: build(item.b_id),
}));
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: "module", label: "新增模块", disabled: !props.canCreate },
{ key: "page", label: "新增页面", disabled: !props.canCreate },
{ type: "divider" },
{
key: "delete",
label: "删除",
danger: true,
disabled: !node || !props.canDelete || ["M_SYSTEM", "M_MODULE"].includes(node.key),
},
],
onClick: ({ key }) => {
if (!node) return;
if (key === "delete") {
emit("delete", node.key);
return;
}
emit("add", key, node.key);
},
};
});
</script>
<template>
@@ -62,43 +87,29 @@ function onSelect(keys) {
<strong>模块</strong>
<span>{{ modules.length }}</span>
</div>
<div class="tree-header-actions">
<a-tooltip title="新增">
<a-dropdown :menu="addMenu" :trigger="['click']" :disabled="!canCreate">
<a-button type="text" size="small" :disabled="!canCreate">
<Plus :size="16" />
</a-button>
</a-dropdown>
</a-tooltip>
<a-tooltip title="刷新">
<a-button type="text" size="small" :loading="loading" @click="emit('refresh')">
<RefreshCw :size="16" />
</a-button>
</a-tooltip>
</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="搜索名称或代码">
<a-input v-model:value="searchText" allow-clear size="small" placeholder="搜索名称或编码">
<template #prefix><Search :size="15" /></template>
</a-input>
<a-spin :spinning="loading">
<a-tree
block-node
default-expand-all
:tree-data="treeData"
:selected-keys="selectedKey ? [selectedKey] : []"
@select="onSelect"
>
<template #title="node">
<div class="tree-title">
<Folder v-if="node.type === 'directory'" :size="15" />
<FileText v-else :size="15" />
<span>{{ node.title }}</span>
<small>{{ node.code }}</small>
</div>
</template>
</a-tree>
<a-spin :spinning="loading" class="tree-spin">
<a-dropdown :menu="contextMenu" :trigger="['contextmenu']">
<a-tree
block-node
default-expand-all
show-line
:tree-data="treeData"
:selected-keys="selectedKey ? [selectedKey] : []"
@right-click="onRightClick"
@select="onSelect"
/>
</a-dropdown>
</a-spin>
</aside>
</template>
@@ -113,45 +124,34 @@ function onSelect(keys) {
background: #fff;
padding: 10px 8px;
}
header,
header > div,
.tree-title {
header > div {
display: flex;
align-items: center;
}
header {
justify-content: space-between;
}
header > div {
gap: 8px;
}
header span {
color: #8c8c8c;
font-size: 12px;
}
.tree-header-actions {
gap: 4px;
}
.tree-title {
min-width: 0;
gap: 7px;
}
.tree-title span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tree-title small {
margin-left: auto;
color: #9ca3af;
font-size: 10px;
}
:deep(.ant-spin-nested-loading) {
.tree-spin {
min-height: 0;
flex: 1;
flex: 1 1 auto;
overflow: auto;
}
:deep(.ant-tree) {
background: transparent;
:deep(.ant-spin-nested-loading),
:deep(.ant-spin-container) {
min-height: 0;
}
</style>