20260722222510
This commit is contained in:
1 parent
d04fad24ed
commit
59b3ccfb1c
36 files changed
+2753
-3064
No files matched your search
@@ -1,27 +1,44 @@
|
||||
<script setup>
|
||||
import { useRoute } from "vue-router";
|
||||
import { Minimize2 } from "@lucide/vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import AppSidebar from "./components/AppSidebar.vue";
|
||||
import AppTabs from "./components/AppTabs.vue";
|
||||
import AppToolbar from "./components/AppToolbar.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-layout class="app-layout">
|
||||
<AppSidebar />
|
||||
<AppSidebar v-show="!appStore.contentMaximized" />
|
||||
|
||||
<a-layout class="app-main">
|
||||
<AppToolbar />
|
||||
<AppTabs />
|
||||
<AppTabs v-show="!appStore.contentMaximized" />
|
||||
|
||||
<a-layout-content class="app-content">
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="slide" mode="out-in">
|
||||
<keep-alive>
|
||||
<component :is="Component" :key="route.fullPath" />
|
||||
<component :is="Component" :key="route.fullPath + ':' + appStore.routeRefreshKey" />
|
||||
</keep-alive>
|
||||
</transition>
|
||||
</router-view>
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<button
|
||||
v-if="appStore.contentMaximized"
|
||||
type="button"
|
||||
class="maximize-exit"
|
||||
aria-label="退出最大化"
|
||||
@click="appStore.toggleContentMaximized"
|
||||
>
|
||||
<Minimize2 />
|
||||
<span>退出最大化</span>
|
||||
</button>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
@@ -76,4 +93,38 @@ import AppToolbar from "./components/AppToolbar.vue";
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.maximize-exit {
|
||||
position: fixed;
|
||||
top: 14px;
|
||||
right: 14px;
|
||||
z-index: 40;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 32px;
|
||||
padding: 0 14px 0 10px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 16px;
|
||||
background: var(--fms-surface);
|
||||
color: var(--fms-text);
|
||||
box-shadow: 0 4px 14px rgb(0 0 0 / 12%);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
box-shadow 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
color: var(--fms-primary, #1677ff);
|
||||
background: var(--fms-hover, #f5f7fa);
|
||||
box-shadow: 0 6px 18px rgb(0 0 0 / 18%);
|
||||
}
|
||||
|
||||
.lucide {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,209 +1,46 @@
|
||||
<script setup>
|
||||
import { computed, h, ref, watch } from "vue";
|
||||
import { computed, h, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
Bug,
|
||||
ChartColumn,
|
||||
CircleQuestionMark,
|
||||
Database,
|
||||
FolderKanban,
|
||||
House,
|
||||
Landmark,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Settings,
|
||||
Wallet,
|
||||
} from "@lucide/vue";
|
||||
import { House, PanelLeftClose, PanelLeftOpen, Settings } from "@lucide/vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { usePermissionStore } from "@/stores/permissions";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
const menuIcon = (icon) => () => h(icon, { size: 16, strokeWidth: 1.8 });
|
||||
const openKeys = ref(["system"]);
|
||||
const effectiveCollapsed = computed(() => appStore.sidebarCollapsed);
|
||||
|
||||
const fallbackMenuItems = [
|
||||
{
|
||||
key: "dashboard",
|
||||
icon: menuIcon(House),
|
||||
label: "工作台",
|
||||
children: [
|
||||
{ key: "/dashboard", label: "概览" },
|
||||
{ key: "/dashboard/table-demo", label: "表格 Demo" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "business",
|
||||
icon: menuIcon(FolderKanban),
|
||||
label: "业务管理",
|
||||
children: [
|
||||
{ key: "/business", label: "业务管理" },
|
||||
{ key: "/business/listcontainer", label: "排车列表" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "finance",
|
||||
icon: menuIcon(Wallet),
|
||||
label: "财务管理",
|
||||
children: [
|
||||
{
|
||||
key: "finance-receivable",
|
||||
label: "应收管理",
|
||||
children: [
|
||||
{ key: "/cw/ys/list", label: "应收费用列表" },
|
||||
{ key: "/cw/ys/listbill", label: "应收账单" },
|
||||
{ key: "/cw/ys/listinvoice", label: "应收发票" },
|
||||
{ key: "/cw/ys/listskd", label: "收款管理" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "finance-payable",
|
||||
label: "应付管理",
|
||||
children: [
|
||||
{ key: "/cw/yf/list", label: "应付费用列表" },
|
||||
{ key: "/cw/yf/listbill", label: "应付账单" },
|
||||
{ key: "/cw/yf/listinvoice", label: "应付发票" },
|
||||
{ key: "/cw/yf/listfkd", label: "付款管理" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "finance-daily",
|
||||
label: "日常费用",
|
||||
children: [
|
||||
{ key: "/cw/assistance/daily-expenses", label: "日常费用" },
|
||||
{ key: "/cw/assistance/payment-account", label: "收付账户" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "statistics",
|
||||
icon: menuIcon(ChartColumn),
|
||||
label: "统计分析",
|
||||
children: [
|
||||
{
|
||||
key: "statistics-receivable",
|
||||
label: "收款统计分析",
|
||||
children: [
|
||||
{ key: "/tj/ystj/tj_nots", label: "应收未收明细" },
|
||||
{ key: "/tj/ystj/tj_notsbydw", label: "应收未收按单位汇总" },
|
||||
{ key: "/tj/ystj/tj_notsbysales", label: "未收款按业务员汇总" },
|
||||
{ key: "/tj/ystj/tj_cwskxz", label: "收款明细统计" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "statistics-payable",
|
||||
label: "付款统计分析",
|
||||
children: [
|
||||
{ key: "/tj/yftj/tj_alone_notf", label: "应付未付明细" },
|
||||
{ key: "/tj/yftj/tj_notfbydw", label: "应付未付按单位汇总" },
|
||||
{ key: "/tj/yftj/tj_cwfkxz", label: "付款明细统计" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "statistics-profit",
|
||||
label: "利润统计分析",
|
||||
children: [
|
||||
{ key: "/tj/lrtj/tj_lr_module_id", label: "利润分析单票明细" },
|
||||
{ key: "/tj/lrtj/tj_lrbysales", label: "利润分析按业务员" },
|
||||
{ key: "/tj/lrtj/tj_lrbycustomer", label: "利润分析按客户" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "management",
|
||||
icon: menuIcon(Landmark),
|
||||
label: "管理中心",
|
||||
children: [
|
||||
{ key: "/shcenter", label: "审批中心" },
|
||||
{ key: "/partners/khht", label: "合同管理" },
|
||||
{ key: "/pricelist/list", label: "运费模版" },
|
||||
{ key: "/system/oa-management/companyAnnouncement", label: "企业公告" },
|
||||
{ key: "/system/approvalSettings", label: "审批设置" },
|
||||
{ key: "/system/oa-management/userReminder", label: "提醒设置" },
|
||||
{ key: "/partners/hl", label: "结算汇率" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "base-data",
|
||||
icon: menuIcon(Database),
|
||||
label: "基础数据",
|
||||
children: [
|
||||
{ key: "/partners", label: "合作伙伴" },
|
||||
{ key: "/partners/contact", label: "联系人" },
|
||||
{ key: "/base/other", label: "其他数据" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "system",
|
||||
icon: menuIcon(Settings),
|
||||
label: "系统管理",
|
||||
children: [
|
||||
{ key: "/system/permission", label: "权限管理" },
|
||||
{ key: "/system/field", label: "字段配置" },
|
||||
{ key: "/system/statisticalSettings", label: "查询设置" },
|
||||
{ key: "/system/mappingConfig", label: "映射配置" },
|
||||
{ key: "/system/autoId", label: "自动编码" },
|
||||
{ key: "/system/modules", label: "模块管理" },
|
||||
{ key: "/system/i18n", label: "多语言管理" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "help",
|
||||
icon: menuIcon(CircleQuestionMark),
|
||||
label: "系统帮助",
|
||||
children: [
|
||||
{ key: "/help", label: "帮助文档" },
|
||||
{ key: "/videos", label: "操作视频" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "test",
|
||||
icon: menuIcon(Bug),
|
||||
label: "功能测试",
|
||||
children: [{ key: "/test/test", label: "测试页面" }],
|
||||
},
|
||||
];
|
||||
|
||||
const menuItems = ref(fallbackMenuItems);
|
||||
|
||||
const selectedKeys = computed(() => [route.meta.menuKey || route.path]);
|
||||
const openKeys = ref([]);
|
||||
const parentKeysByRoute = computed(() => {
|
||||
const result = new Map();
|
||||
const visit = (items, parents = []) => {
|
||||
items.forEach((item) => {
|
||||
if (item.children?.length) visit(item.children, [...parents, item.key]);
|
||||
else result.set(item.key, parents);
|
||||
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" },
|
||||
].filter((item) => permissionStore.canModule(item.moduleCode));
|
||||
const items = [{ key: "/dashboard", icon: menuIcon(House), label: "工作台" }];
|
||||
if (systemPages.length)
|
||||
items.push({
|
||||
key: "system",
|
||||
icon: menuIcon(Settings),
|
||||
label: "系统管理",
|
||||
children: systemPages,
|
||||
});
|
||||
};
|
||||
visit(menuItems.value);
|
||||
return result;
|
||||
return items;
|
||||
});
|
||||
|
||||
const selectedKeys = computed(() => [route.meta.menuKey || route.path]);
|
||||
watch(
|
||||
[() => route.meta.menuKey || route.path, menuItems],
|
||||
([menuKey]) => {
|
||||
const parentKeys = parentKeysByRoute.value.get(menuKey) || [];
|
||||
const nextKeys = new Set(openKeys.value);
|
||||
parentKeys.forEach((key) => nextKeys.add(key));
|
||||
if (nextKeys.size !== openKeys.value.length) {
|
||||
openKeys.value = Array.from(nextKeys);
|
||||
}
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (path.startsWith("/system") && !openKeys.value.includes("system"))
|
||||
openKeys.value = [...openKeys.value, "system"];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function navigate({ key }) {
|
||||
if (typeof key === "string" && key.startsWith("/")) {
|
||||
router.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
function updateOpenKeys(keys) {
|
||||
openKeys.value = keys;
|
||||
if (typeof key === "string" && key.startsWith("/")) router.push(key);
|
||||
}
|
||||
onMounted(() => permissionStore.load().catch(() => {}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -218,11 +55,8 @@ function updateOpenKeys(keys) {
|
||||
>
|
||||
<div class="brand" :class="{ 'brand--collapsed': effectiveCollapsed }">
|
||||
<div class="brand__mark">F</div>
|
||||
<div class="brand__copy" :aria-hidden="effectiveCollapsed">
|
||||
<strong>FMS 管理系统</strong>
|
||||
</div>
|
||||
<strong>FMS 管理系统</strong>
|
||||
</div>
|
||||
|
||||
<div class="sider-menu">
|
||||
<a-menu
|
||||
mode="inline"
|
||||
@@ -230,49 +64,31 @@ function updateOpenKeys(keys) {
|
||||
:selected-keys="selectedKeys"
|
||||
:open-keys="openKeys"
|
||||
@click="navigate"
|
||||
@open-change="updateOpenKeys"
|
||||
@open-change="openKeys = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sider-footer">
|
||||
<a-tooltip :title="effectiveCollapsed ? '展开侧栏' : '收起侧栏'" placement="right">
|
||||
<a-button
|
||||
type="text"
|
||||
class="sider-collapse"
|
||||
aria-label="切换侧栏宽度"
|
||||
@click="appStore.toggleSidebar"
|
||||
>
|
||||
<template #icon>
|
||||
<PanelLeftOpen v-if="effectiveCollapsed" />
|
||||
<PanelLeftClose v-else />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
<a-tooltip :title="effectiveCollapsed ? '展开侧栏' : '收起侧栏'" placement="right"
|
||||
><a-button type="text" class="sider-collapse" @click="appStore.toggleSidebar"
|
||||
><PanelLeftOpen v-if="effectiveCollapsed" /><PanelLeftClose v-else /></a-button
|
||||
></a-tooltip>
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
<style scoped lang="scss">
|
||||
.app-sider {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--fms-border);
|
||||
will-change: width;
|
||||
|
||||
:deep(.ant-layout-sider-children) {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.ant-menu) {
|
||||
border-inline-end: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-item),
|
||||
:deep(.ant-menu-submenu-title) {
|
||||
width: 100%;
|
||||
@@ -281,88 +97,43 @@ function updateOpenKeys(keys) {
|
||||
border-radius: 0;
|
||||
line-height: 36px;
|
||||
}
|
||||
|
||||
:deep(.ant-menu-root > .ant-menu-item),
|
||||
:deep(.ant-menu-root > .ant-menu-submenu > .ant-menu-submenu-title) {
|
||||
padding-inline-start: 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px 6px 15px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand__mark {
|
||||
display: grid;
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
.brand strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
.brand--collapsed strong {
|
||||
display: none;
|
||||
}
|
||||
.sider-menu {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sider-footer {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
border-top: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
.sider-collapse {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding-inline: 0;
|
||||
border-radius: 4px;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
gap: 8px;
|
||||
padding: 6px 12px 6px 15px;
|
||||
|
||||
&--collapsed {
|
||||
justify-content: flex-start;
|
||||
gap: 0;
|
||||
padding-inline: 15px;
|
||||
|
||||
.brand__copy {
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
&__mark {
|
||||
display: grid;
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&__copy {
|
||||
display: flex;
|
||||
max-width: 140px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
opacity: 1;
|
||||
line-height: 1;
|
||||
transform: translateX(0);
|
||||
transition:
|
||||
max-width 0.18s ease,
|
||||
opacity 0.12s ease,
|
||||
transform 0.18s ease;
|
||||
|
||||
strong {
|
||||
color: var(--fms-text);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ChevronLeft, ChevronRight, X } from "@lucide/vue";
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, House, Maximize2, Minimize2, RotateCw, X } from "@lucide/vue";
|
||||
import { theme } from "antdv-next";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
@@ -136,6 +136,22 @@ function handleTabKeydown(event, index) {
|
||||
event.preventDefault();
|
||||
focusAdjacentTab(index, event.key === "ArrowLeft" ? -1 : 1);
|
||||
}
|
||||
|
||||
function goConsole() {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
function refreshCurrentRoute() {
|
||||
appStore.refreshRoute();
|
||||
}
|
||||
|
||||
function toggleMaximize() {
|
||||
appStore.toggleContentMaximized();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -148,6 +164,41 @@ function handleTabKeydown(event, index) {
|
||||
'--tabs-idle-bg': token.colorFillQuaternary,
|
||||
}"
|
||||
>
|
||||
<div class="tab-actions">
|
||||
<a-tooltip title="返回上一页" placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
class="tab-action"
|
||||
aria-label="返回上一页"
|
||||
@click="goBack"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
<span class="tab-action__divider" />
|
||||
<a-tooltip title="刷新当前页" placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
class="tab-action"
|
||||
aria-label="刷新当前页"
|
||||
@click="refreshCurrentRoute"
|
||||
>
|
||||
<RotateCw />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
<span class="tab-action__divider" />
|
||||
<a-tooltip title="返回控制台" placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
class="tab-action"
|
||||
aria-label="返回控制台"
|
||||
@click="goConsole"
|
||||
>
|
||||
<House />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="overflowed"
|
||||
type="button"
|
||||
@@ -200,6 +251,18 @@ function handleTabKeydown(event, index) {
|
||||
>
|
||||
<ChevronRight />
|
||||
</button>
|
||||
|
||||
<a-tooltip :title="appStore.contentMaximized ? '退出最大化' : '应用内最大化'" placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
class="tab-nav-button tab-maximize"
|
||||
aria-label="应用内最大化"
|
||||
@click="toggleMaximize"
|
||||
>
|
||||
<Minimize2 v-if="appStore.contentMaximized" />
|
||||
<Maximize2 v-else />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
@@ -319,4 +382,51 @@ function handleTabKeydown(event, index) {
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.tab-action {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: var(--tabs-active-color);
|
||||
background: var(--tabs-active-bg);
|
||||
}
|
||||
|
||||
.lucide {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-action__divider {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--fms-border);
|
||||
}
|
||||
|
||||
.tab-maximize {
|
||||
margin-left: 2px;
|
||||
}
|
||||
</style>
|
||||
@@ -64,7 +64,7 @@ function confirmLogout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="top-toolbar">
|
||||
<header class="top-toolbar" :class="{ 'top-toolbar--hidden': appStore.contentMaximized }">
|
||||
<a-breadcrumb class="toolbar-breadcrumb" separator=">">
|
||||
<a-breadcrumb-item v-for="item in breadcrumbItems" :key="item.label">
|
||||
{{ item.label }}
|
||||
@@ -125,6 +125,10 @@ function confirmLogout() {
|
||||
padding: 0 8px 0 12px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
background: var(--fms-surface);
|
||||
|
||||
&--hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-breadcrumb {
|
||||
|
||||
@@ -25,14 +25,25 @@ const routes = [
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "system/modules",
|
||||
name: "system-modules",
|
||||
path: "system/metadata",
|
||||
name: "system-metadata",
|
||||
component: () => import("@/views/module-management/index.vue"),
|
||||
meta: {
|
||||
title: "模块管理",
|
||||
title: "元数据配置",
|
||||
section: "系统管理",
|
||||
menuKey: "/system/modules",
|
||||
moduleCode: "m_module",
|
||||
menuKey: "/system/metadata",
|
||||
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",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -9,11 +9,21 @@ export const useAppStore = defineStore(
|
||||
const locale = ref("zh-CN");
|
||||
const sidebarCollapsed = ref(false);
|
||||
const visitedTabs = ref(createDefaultVisitedTabs());
|
||||
const contentMaximized = ref(false);
|
||||
const routeRefreshKey = ref(0);
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value;
|
||||
}
|
||||
|
||||
function toggleContentMaximized() {
|
||||
contentMaximized.value = !contentMaximized.value;
|
||||
}
|
||||
|
||||
function refreshRoute() {
|
||||
routeRefreshKey.value++;
|
||||
}
|
||||
|
||||
function setLocale(value) {
|
||||
locale.value = value;
|
||||
}
|
||||
@@ -32,7 +42,11 @@ export const useAppStore = defineStore(
|
||||
locale,
|
||||
sidebarCollapsed,
|
||||
visitedTabs,
|
||||
contentMaximized,
|
||||
routeRefreshKey,
|
||||
toggleSidebar,
|
||||
toggleContentMaximized,
|
||||
refreshRoute,
|
||||
setLocale,
|
||||
addVisitedTab,
|
||||
removeVisitedTab,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { nextIdApi } from "@/services/api";
|
||||
|
||||
export function cloneData(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export function diffRows(current, previous) {
|
||||
const previousById = new Map(previous.map((item) => [String(item.b_id), item]));
|
||||
const currentById = new Map(current.map((item) => [String(item.b_id), item]));
|
||||
return {
|
||||
inserts: current.filter((item) => !previousById.has(String(item.b_id))),
|
||||
updates: current.filter((item) => {
|
||||
const old = previousById.get(String(item.b_id));
|
||||
return old && JSON.stringify(item) !== JSON.stringify(old);
|
||||
}),
|
||||
deletes: previous
|
||||
.filter((item) => !currentById.has(String(item.b_id)))
|
||||
.map((item) => ({ b_id: item.b_id })),
|
||||
};
|
||||
}
|
||||
|
||||
export async function allocateTemporaryIds(rowGroups) {
|
||||
const temporaryIds = [
|
||||
...new Set(
|
||||
rowGroups
|
||||
.flat()
|
||||
.map((row) => String(row?.b_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)]));
|
||||
rowGroups.flat().forEach((row) => {
|
||||
const id = mapping.get(String(row?.b_id));
|
||||
if (id) row.b_id = id;
|
||||
});
|
||||
return mapping;
|
||||
}
|
||||
|
||||
export function remapForeignKeys(rows, fields, mapping) {
|
||||
rows.forEach((row) => {
|
||||
fields.forEach((field) => {
|
||||
const value = mapping.get(String(row[field]));
|
||||
if (value) row[field] = value;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function tableChange(table, changes) {
|
||||
return { table, key_field: "b_id", ...changes };
|
||||
}
|
||||
|
||||
export function hasChanges(request) {
|
||||
return request.inserts?.length || request.updates?.length || request.deletes?.length;
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
<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,533 +1,193 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { App } from "antdv-next";
|
||||
import { Languages, Plus, RefreshCw, RotateCcw, Save, Trash2 } from "@lucide/vue";
|
||||
import { Languages, Plus, RefreshCw, Save, Trash2 } from "@lucide/vue";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
import { loadDataApi, nextIdApi, saveObjectApi } from "@/services/api";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { usePermissionStore } from "@/stores/permissions";
|
||||
import {
|
||||
I18N_LOCALES,
|
||||
I18N_LOCALE_CODES,
|
||||
getI18nNamespace,
|
||||
groupI18nRows,
|
||||
isValidI18nKey,
|
||||
} from "@/utils/i18n";
|
||||
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 route = useRoute();
|
||||
const authStore = useAuthStore();
|
||||
const permissionStore = usePermissionStore();
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const original = ref("");
|
||||
const groups = ref({});
|
||||
const keyword = ref("");
|
||||
const rows = ref([]);
|
||||
const original = ref([]);
|
||||
const activeRowKey = ref(null);
|
||||
const selectedRowKeys = ref([]);
|
||||
const keyModuleCodes = ref(new Map());
|
||||
const data = reactive({ modules: [], fields: [], powers: [], translations: [] });
|
||||
const filters = reactive({
|
||||
keyword: "",
|
||||
namespace: undefined,
|
||||
moduleCode: undefined,
|
||||
locale: undefined,
|
||||
status: "all",
|
||||
});
|
||||
const createDialog = reactive({ open: false, key: "", values: {} });
|
||||
const deleteDialogOpen = ref(false);
|
||||
|
||||
const canCreate = computed(() => permissionStore.canPower("m_i18n", "create"));
|
||||
const canUpdate = computed(() => permissionStore.canPower("m_i18n", "update"));
|
||||
const canDelete = computed(() => permissionStore.canPower("m_i18n", "delete"));
|
||||
const dirty = computed(() => serializeGroups(groups.value) !== original.value);
|
||||
const moduleOptions = computed(() =>
|
||||
data.modules.map((item) => ({
|
||||
value: item.b_code,
|
||||
label: item.b_name + " (" + item.b_code + ")",
|
||||
})),
|
||||
);
|
||||
|
||||
const allRows = computed(() =>
|
||||
Object.values(groups.value)
|
||||
.map((group) => {
|
||||
const missingLocales = I18N_LOCALE_CODES.filter((locale) => !group.values[locale]?.trim());
|
||||
return {
|
||||
...group,
|
||||
...Object.fromEntries(
|
||||
I18N_LOCALE_CODES.map((locale) => [locale, group.values[locale] || ""]),
|
||||
),
|
||||
namespace: getI18nNamespace(group.key),
|
||||
moduleCodes: Array.from(keyModuleCodes.value.get(group.key) || inferModuleCodes(group.key)),
|
||||
moduleCodesText:
|
||||
Array.from(keyModuleCodes.value.get(group.key) || inferModuleCodes(group.key)).join(
|
||||
"、",
|
||||
) || "通用 / 未绑定",
|
||||
missingLocales,
|
||||
statusText: missingLocales.length ? `缺 ${missingLocales.length}` : "完整",
|
||||
};
|
||||
})
|
||||
.sort((left, right) => left.key.localeCompare(right.key)),
|
||||
);
|
||||
|
||||
const namespaceOptions = computed(() =>
|
||||
Array.from(new Set(allRows.value.map((item) => item.namespace)))
|
||||
.sort()
|
||||
.map((value) => ({ value, label: value })),
|
||||
);
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const keyword = filters.keyword.trim().toLowerCase();
|
||||
return allRows.value.filter((row) => {
|
||||
if (keyword) {
|
||||
const searchText = [row.key, ...Object.values(row.values)].join(" ").toLowerCase();
|
||||
if (!searchText.includes(keyword)) return false;
|
||||
}
|
||||
if (filters.namespace && row.namespace !== filters.namespace) return false;
|
||||
if (filters.moduleCode && !row.moduleCodes.includes(filters.moduleCode)) return false;
|
||||
|
||||
const focusedLocales = filters.locale ? [filters.locale] : I18N_LOCALE_CODES;
|
||||
const missingCount = focusedLocales.filter((locale) => !row.values[locale]?.trim()).length;
|
||||
if (filters.status === "missing" && missingCount === 0) return false;
|
||||
if (filters.status === "complete" && missingCount > 0) return false;
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
const visibleLocales = computed(() =>
|
||||
filters.locale ? I18N_LOCALES.filter((item) => item.value === filters.locale) : I18N_LOCALES,
|
||||
);
|
||||
|
||||
const columns = computed(() => [
|
||||
{ title: "多语言键", dataIndex: "key", key: "key", width: 280, fixed: "left" },
|
||||
{ title: "命名空间", dataIndex: "namespace", key: "namespace", width: 110 },
|
||||
{ title: "关联模块", dataIndex: "moduleCodesText", key: "moduleCodes", width: 190 },
|
||||
...visibleLocales.value.map((locale) => ({
|
||||
title: locale.label,
|
||||
dataIndex: locale.value,
|
||||
key: locale.value,
|
||||
locale: locale.value,
|
||||
width: 250,
|
||||
editor: {
|
||||
maxlength: 500,
|
||||
disabled: (row) => !canEditRow(row),
|
||||
placeholder: `填写 ${locale.value} 文案`,
|
||||
status: (row) => (row[locale.value]?.trim() ? undefined : "warning"),
|
||||
onChange: (value, row) => {
|
||||
row.values[locale.value] = value;
|
||||
},
|
||||
},
|
||||
})),
|
||||
{ title: "状态", dataIndex: "statusText", key: "status", width: 100, fixed: "right" },
|
||||
]);
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys;
|
||||
},
|
||||
}));
|
||||
|
||||
function serializeGroups(value) {
|
||||
return JSON.stringify(
|
||||
Object.values(value)
|
||||
.map((item) => ({
|
||||
key: item.key,
|
||||
values: Object.fromEntries(
|
||||
I18N_LOCALE_CODES.map((locale) => [locale, item.values[locale] || ""]),
|
||||
),
|
||||
}))
|
||||
.sort((left, right) => left.key.localeCompare(right.key)),
|
||||
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),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function inferModuleCodes(key) {
|
||||
const parts = String(key).split(".");
|
||||
return new Set(data.modules.map((item) => item.b_code).filter((code) => parts.includes(code)));
|
||||
}
|
||||
|
||||
function rebuildModuleReferences() {
|
||||
const moduleById = new Map(data.modules.map((item) => [String(item.b_id), item.b_code]));
|
||||
const result = new Map();
|
||||
const bind = (key, moduleCode) => {
|
||||
if (!key || !moduleCode) return;
|
||||
if (!result.has(key)) result.set(key, new Set());
|
||||
result.get(key).add(moduleCode);
|
||||
};
|
||||
data.modules.forEach((item) => bind(item.b_i18n, item.b_code));
|
||||
data.fields.forEach((item) => bind(item.b_i18n, moduleById.get(String(item.b_module_id))));
|
||||
data.powers.forEach((item) => bind(item.b_i18n, moduleById.get(String(item.b_module_id))));
|
||||
keyModuleCodes.value = result;
|
||||
}
|
||||
|
||||
function resetDraft() {
|
||||
const nextGroups = groupI18nRows(data.translations);
|
||||
[...data.modules, ...data.fields, ...data.powers].forEach((item) => {
|
||||
if (item.b_i18n) {
|
||||
nextGroups[item.b_i18n] ||= { key: item.b_i18n, values: {}, records: {} };
|
||||
}
|
||||
});
|
||||
groups.value = nextGroups;
|
||||
original.value = serializeGroups(groups.value);
|
||||
selectedRowKeys.value = [];
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [modules, fields, powers, translations] = await Promise.all([
|
||||
loadDataApi("b_module", null, "b_xh ASC"),
|
||||
loadDataApi("b_module_field", null, "b_xh ASC"),
|
||||
loadDataApi("b_module_power", null, "b_xh ASC"),
|
||||
loadDataApi("b_i18n", null, "b_key ASC"),
|
||||
]);
|
||||
Object.assign(data, {
|
||||
modules: modules.data,
|
||||
fields: fields.data,
|
||||
powers: powers.data,
|
||||
translations: translations.data,
|
||||
});
|
||||
rebuildModuleReferences();
|
||||
resetDraft();
|
||||
if (typeof route.query.module === "string") filters.moduleCode = route.query.module;
|
||||
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 || "多语言资源加载失败");
|
||||
message.error(error.message || "多语言加载失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
const modulePrefix = filters.moduleCode ? "." + filters.moduleCode + "." : ".";
|
||||
createDialog.key = (filters.namespace || "common") + modulePrefix;
|
||||
createDialog.values = Object.fromEntries(I18N_LOCALE_CODES.map((locale) => [locale, ""]));
|
||||
createDialog.open = true;
|
||||
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 addKey() {
|
||||
const key = createDialog.key.trim();
|
||||
if (!isValidI18nKey(key)) {
|
||||
message.warning("多语言键需以字母开头,只能包含字母、数字、点、冒号、短横线和下划线");
|
||||
return;
|
||||
}
|
||||
if (groups.value[key]) {
|
||||
message.warning("多语言键已经存在");
|
||||
return;
|
||||
}
|
||||
if (!I18N_LOCALE_CODES.some((locale) => createDialog.values[locale]?.trim())) {
|
||||
message.warning("请至少填写一种语言的文案");
|
||||
return;
|
||||
}
|
||||
groups.value = {
|
||||
...groups.value,
|
||||
[key]: { key, values: { ...createDialog.values }, records: {} },
|
||||
};
|
||||
createDialog.open = false;
|
||||
}
|
||||
|
||||
function requestDeleteSelected() {
|
||||
if (!selectedRowKeys.value.length) return;
|
||||
deleteDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function deleteSelected() {
|
||||
const selected = new Set(selectedRowKeys.value);
|
||||
groups.value = Object.fromEntries(
|
||||
Object.entries(groups.value).filter(([key]) => !selected.has(key)),
|
||||
);
|
||||
function removeSelected() {
|
||||
const selected = new Set(selectedRowKeys.value.map(String));
|
||||
rows.value = rows.value.filter((row) => !selected.has(String(row.b_id)));
|
||||
selectedRowKeys.value = [];
|
||||
deleteDialogOpen.value = false;
|
||||
activeRowKey.value = null;
|
||||
}
|
||||
|
||||
function canEditRow(record) {
|
||||
return Object.keys(record.records || {}).length ? canUpdate.value : canCreate.value;
|
||||
}
|
||||
|
||||
async function saveTranslations() {
|
||||
if (!dirty.value) return;
|
||||
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 {
|
||||
const oldMap = new Map(
|
||||
data.translations.map((item) => [item.b_key + "\u0000" + item.b_locale, item]),
|
||||
);
|
||||
const desiredMap = new Map();
|
||||
Object.values(groups.value).forEach((group) => {
|
||||
I18N_LOCALE_CODES.forEach((locale) => {
|
||||
const value = group.values[locale]?.trim();
|
||||
if (value) {
|
||||
desiredMap.set(group.key + "\u0000" + locale, { key: group.key, locale, value });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const inserts = [];
|
||||
const updates = [];
|
||||
const deletes = [];
|
||||
const now = new Date().toISOString().slice(0, 19).replace("T", " ");
|
||||
|
||||
for (const [compoundKey, item] of desiredMap) {
|
||||
const old = oldMap.get(compoundKey);
|
||||
if (!old) {
|
||||
const id = String((await nextIdApi()).data);
|
||||
inserts.push({
|
||||
b_id: id,
|
||||
b_key: item.key,
|
||||
b_locale: item.locale,
|
||||
b_value: item.value,
|
||||
b_inputuser_id: String(authStore.user.id),
|
||||
b_inputdatetime: now,
|
||||
b_updateuser_id: null,
|
||||
b_updatedatetime: null,
|
||||
});
|
||||
} else if (old.b_value !== item.value) {
|
||||
updates.push({
|
||||
...old,
|
||||
b_value: item.value,
|
||||
b_updateuser_id: String(authStore.user.id),
|
||||
b_updatedatetime: now,
|
||||
});
|
||||
}
|
||||
await allocateTemporaryIds([rows.value]);
|
||||
const changes = diffRows(rows.value, original.value);
|
||||
if (!changes.inserts.length && !changes.updates.length && !changes.deletes.length) {
|
||||
return message.info("没有需要保存的修改");
|
||||
}
|
||||
|
||||
for (const [compoundKey, old] of oldMap) {
|
||||
const keyWasDeleted = !groups.value[old.b_key];
|
||||
const managedLocale = I18N_LOCALE_CODES.includes(old.b_locale);
|
||||
if (!desiredMap.has(compoundKey) && (keyWasDeleted || managedLocale)) {
|
||||
deletes.push({ b_id: old.b_id });
|
||||
}
|
||||
}
|
||||
|
||||
if (!inserts.length && !updates.length && !deletes.length) {
|
||||
resetDraft();
|
||||
message.info("绑定中的空 Key 会继续保留,以便补充缺失翻译");
|
||||
return;
|
||||
}
|
||||
await saveObjectApi([{ table: "b_i18n", key_field: "b_id", inserts, updates, deletes }]);
|
||||
message.success("多语言资源已保存");
|
||||
await loadAll();
|
||||
await saveObjectApi([tableChange("b_i18n", changes)]);
|
||||
message.success("多语言已保存");
|
||||
await load();
|
||||
} catch (error) {
|
||||
message.error(error.message || "多语言资源保存失败");
|
||||
message.error(error.message || "保存失败");
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(filters, {
|
||||
keyword: "",
|
||||
namespace: undefined,
|
||||
moduleCode: undefined,
|
||||
locale: undefined,
|
||||
status: "all",
|
||||
});
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.module,
|
||||
(value) => {
|
||||
if (typeof value === "string") filters.moduleCode = value;
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await permissionStore.load();
|
||||
await loadAll();
|
||||
});
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="i18n-management">
|
||||
<section class="i18n-page">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<div class="page-title"><Languages />多语言资源中心</div>
|
||||
<p>统一维护菜单、模块、权限、字段、提示、按钮与校验文案。</p>
|
||||
<div class="page-title">
|
||||
<h1><Languages />多语言管理</h1>
|
||||
<span>{{ rows.length }} 条翻译</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<a-button :disabled="!dirty" @click="resetDraft"><RotateCcw />撤销修改</a-button>
|
||||
<a-button :loading="loading" @click="loadAll"><RefreshCw />刷新</a-button>
|
||||
<a-button type="primary" :loading="saving" :disabled="!dirty" @click="saveTranslations">
|
||||
<Save />保存
|
||||
<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>
|
||||
|
||||
<div class="filter-bar">
|
||||
<a-input-search v-model:value="filters.keyword" allow-clear placeholder="搜索 key 或文案" />
|
||||
<a-select
|
||||
v-model:value="filters.namespace"
|
||||
allow-clear
|
||||
placeholder="命名空间"
|
||||
:options="namespaceOptions"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filters.moduleCode"
|
||||
show-search
|
||||
allow-clear
|
||||
placeholder="关联模块"
|
||||
:options="moduleOptions"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filters.locale"
|
||||
allow-clear
|
||||
placeholder="聚焦语言"
|
||||
:options="I18N_LOCALES"
|
||||
/>
|
||||
<a-select
|
||||
v-model:value="filters.status"
|
||||
:options="[
|
||||
{ value: 'all', label: '全部状态' },
|
||||
{ value: 'missing', label: '存在缺失' },
|
||||
{ value: 'complete', label: '翻译完整' },
|
||||
]"
|
||||
/>
|
||||
<a-button @click="resetFilters">重置筛选</a-button>
|
||||
</div>
|
||||
|
||||
<div class="table-toolbar">
|
||||
<div class="toolbar-actions">
|
||||
<a-button type="primary" :disabled="!canCreate" @click="openCreateDialog">
|
||||
<Plus />新增 Key
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
:disabled="!canDelete || !selectedRowKeys.length"
|
||||
@click="requestDeleteSelected"
|
||||
>
|
||||
<Trash2 />批量删除
|
||||
</a-button>
|
||||
</div>
|
||||
<span>共 {{ filteredRows.length }} 个 Key,已选择 {{ selectedRowKeys.length }} 个</span>
|
||||
</div>
|
||||
|
||||
<div class="table-wrap">
|
||||
<a-input-search
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
placeholder="搜索多语言键、语言或内容"
|
||||
class="search"
|
||||
/>
|
||||
<div class="table-body">
|
||||
<a-spin :spinning="loading">
|
||||
<FmsTable
|
||||
row-key="key"
|
||||
:col-resizable="false"
|
||||
: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>
|
||||
|
||||
<a-modal
|
||||
v-model:open="createDialog.open"
|
||||
title="新增多语言 Key"
|
||||
ok-text="添加"
|
||||
cancel-text="取消"
|
||||
@ok="addKey"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item
|
||||
label="多语言键"
|
||||
required
|
||||
extra="建议使用 namespace.module_code.name,例如 field.m_biz.customer.placeholder"
|
||||
>
|
||||
<a-input v-model:value="createDialog.key" :maxlength="150" />
|
||||
</a-form-item>
|
||||
<a-form-item v-for="locale in I18N_LOCALES" :key="locale.value" :label="locale.label">
|
||||
<a-input v-model:value="createDialog.values[locale.value]" :maxlength="500" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
<a-modal
|
||||
v-model:open="deleteDialogOpen"
|
||||
title="删除多语言 Key"
|
||||
ok-text="删除"
|
||||
cancel-text="取消"
|
||||
ok-type="danger"
|
||||
@ok="deleteSelected"
|
||||
>
|
||||
<p>
|
||||
确定删除选中的 {{ selectedRowKeys.length }} 个 Key 及其全部语言文本吗?已绑定这些 Key
|
||||
的业务对象将显示回退文案。
|
||||
</p>
|
||||
</a-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.i18n-management {
|
||||
<style scoped lang="scss">
|
||||
.i18n-page {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 6px;
|
||||
background: var(--fms-surface);
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.filter-bar,
|
||||
.table-toolbar,
|
||||
.header-actions,
|
||||
.toolbar-actions,
|
||||
.page-title {
|
||||
.page-title,
|
||||
.page-title h1,
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.table-toolbar {
|
||||
.page-header {
|
||||
flex: none;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
.page-title,
|
||||
.page-title h1,
|
||||
.header-actions {
|
||||
gap: 8px;
|
||||
color: var(--fms-text);
|
||||
font-size: 20px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.table-toolbar,
|
||||
.muted {
|
||||
.page-title h1 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.page-title span {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
.search {
|
||||
width: 360px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.header-actions,
|
||||
.toolbar-actions,
|
||||
.filter-bar {
|
||||
gap: 8px;
|
||||
}
|
||||
.table-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.filter-bar {
|
||||
flex-wrap: wrap;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--fms-bg);
|
||||
}
|
||||
|
||||
.filter-bar :deep(.ant-input-search) {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.filter-bar :deep(.ant-select) {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
min-width: 0;
|
||||
min-height: 420px;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.table-wrap :deep(.ant-spin-nested-loading),
|
||||
.table-wrap :deep(.ant-spin-container),
|
||||
.table-wrap :deep(.ant-spin) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.table-wrap :deep(.stk-table .ant-input) {
|
||||
min-width: 0;
|
||||
:deep(.ant-spin-nested-loading),
|
||||
:deep(.ant-spin-container) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<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,165 +0,0 @@
|
||||
<script setup>
|
||||
import ModuleTranslationEditor from "./ModuleTranslationEditor.vue";
|
||||
|
||||
const props = defineProps({
|
||||
directoryParentOptions: { type: Array, default: () => [] },
|
||||
parentOptions: { type: Array, default: () => [] },
|
||||
});
|
||||
const emit = defineEmits(["update-module-code"]);
|
||||
const module = defineModel("module", { type: Object, default: null });
|
||||
const translations = defineModel("translations", { type: Object, default: () => ({}) });
|
||||
|
||||
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_code" @update:value="emit('update-module-code', $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="directoryParentOptions.filter((item) => item.value !== module.b_id)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="多语言键">
|
||||
<div class="i18n-editor-row">
|
||||
<a-input v-model:value="module.b_i18n" />
|
||||
<ModuleTranslationEditor
|
||||
v-model:translations="translations"
|
||||
:i18n-key="module.b_i18n"
|
||||
:default-name="module.b_name"
|
||||
:disabled="!module.b_i18n?.trim()"
|
||||
/>
|
||||
</div>
|
||||
</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_code" @update:value="emit('update-module-code', $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="多语言键">
|
||||
<div class="i18n-editor-row">
|
||||
<a-input v-model:value="module.b_i18n" />
|
||||
<ModuleTranslationEditor
|
||||
v-model:translations="translations"
|
||||
:i18n-key="module.b_i18n"
|
||||
:default-name="module.b_name"
|
||||
:disabled="!module.b_i18n?.trim()"
|
||||
/>
|
||||
</div>
|
||||
</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-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 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.i18n-editor-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.i18n-editor-row :deep(.ant-input) {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
: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,65 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
parentOptions: { type: Array, default: () => [] },
|
||||
routeOptions: { type: Array, default: () => [] },
|
||||
});
|
||||
const module = defineModel("module", { type: Object, required: true });
|
||||
function setBoolean(field, checked) {
|
||||
module.value[field] = checked ? 1 : 0;
|
||||
}
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.basic-form {
|
||||
display: grid;
|
||||
max-width: 1080px;
|
||||
grid-template-columns: repeat(3, minmax(180px, 1fr));
|
||||
gap: 0 16px;
|
||||
padding-top: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<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>
|
||||
-213
@@ -1,213 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { App } from "antdv-next";
|
||||
import { RefreshCw } from "@lucide/vue";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
import { describeApi } from "@/services/api";
|
||||
import { createTempId } from "@/utils/tempId";
|
||||
import { createI18nCellRenderer } from "./createI18nCellRenderer";
|
||||
import ModuleTranslationEditor from "./ModuleTranslationEditor.vue";
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: [String, Number], default: "" },
|
||||
moduleCode: { type: String, default: "" },
|
||||
// 字段来源:模块配置的查询表或视图,为空时无法同步。
|
||||
viewTable: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const fields = defineModel("fields", { type: Array, default: () => [] });
|
||||
const translations = defineModel("translations", { type: Object, default: () => ({}) });
|
||||
const { message } = App.useApp();
|
||||
// 当前编辑行 key;点击某行进入单行编辑,再次点击退出。
|
||||
const activeRowKey = ref(null);
|
||||
const syncing = ref(false);
|
||||
const translationEditorRef = ref(null);
|
||||
|
||||
const renderI18nCell = createI18nCellRenderer({
|
||||
activeRowKey,
|
||||
translationEditorRef,
|
||||
});
|
||||
|
||||
function onRowClick(_ev, row) {
|
||||
const key = String(row.b_id);
|
||||
activeRowKey.value = activeRowKey.value === key ? null : key;
|
||||
}
|
||||
|
||||
function mapType(type) {
|
||||
const t = String(type || "").toLowerCase();
|
||||
if (t.includes("bit")) return "boolean";
|
||||
if (/(tinyint|smallint|int|bigint|decimal|numeric|float|real|money|smallmoney)/.test(t)) {
|
||||
return "number";
|
||||
}
|
||||
if (/(datetime|smalldatetime|date|time)/.test(t)) return "date";
|
||||
if (/(text|ntext)/.test(t)) return "text";
|
||||
return "string";
|
||||
}
|
||||
|
||||
// 从视图同步字段:保留已有配置,追加视图新增列,移除视图已不存在的列。
|
||||
async function syncFields() {
|
||||
const source = props.viewTable;
|
||||
if (!source) {
|
||||
message.warning("请先在基本信息中配置查询表或视图");
|
||||
return;
|
||||
}
|
||||
syncing.value = true;
|
||||
try {
|
||||
const columns = (await describeApi(source)).data || [];
|
||||
if (!columns.length) {
|
||||
message.warning("该视图没有可识别的列");
|
||||
return;
|
||||
}
|
||||
|
||||
const existingByField = new Map(fields.value.map((item) => [item.b_field, item]));
|
||||
const merged = [];
|
||||
let maxXh = 0;
|
||||
fields.value.forEach((item) => {
|
||||
if (columns.some((col) => col.name === item.b_field)) {
|
||||
merged.push(item);
|
||||
maxXh = Math.max(maxXh, Number(item.b_xh) || 0);
|
||||
}
|
||||
});
|
||||
for (const col of columns) {
|
||||
if (existingByField.has(col.name)) continue;
|
||||
maxXh += 10;
|
||||
merged.push({
|
||||
b_id: createTempId(),
|
||||
b_module_id: props.moduleId,
|
||||
b_field: col.name,
|
||||
b_name: col.name,
|
||||
b_i18n: props.moduleCode ? `field.${props.moduleCode}.${col.name}` : null,
|
||||
b_type: mapType(col.type),
|
||||
b_canlist: 1,
|
||||
b_canform: 1,
|
||||
b_must: 0,
|
||||
b_canquery: 0,
|
||||
b_readonly: 0,
|
||||
b_width: 120,
|
||||
b_value: null,
|
||||
b_xh: maxXh,
|
||||
b_canuse: 1,
|
||||
});
|
||||
}
|
||||
fields.value = merged;
|
||||
message.success(`已同步 ${columns.length} 个字段`);
|
||||
} catch (error) {
|
||||
message.error(error.message || "字段同步失败");
|
||||
} finally {
|
||||
syncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = ref([
|
||||
{
|
||||
dataIndex: "b_i18n",
|
||||
title: "多语言键",
|
||||
width: 250,
|
||||
customCell: renderI18nCell,
|
||||
},
|
||||
{ dataIndex: "b_field", title: "字段", width: 150, editor: { maxlength: 50 } },
|
||||
{ dataIndex: "b_name", title: "名称", width: 150, editor: { maxlength: 100 } },
|
||||
{
|
||||
dataIndex: "b_type",
|
||||
title: "类型",
|
||||
width: 120,
|
||||
editor: {
|
||||
type: "select",
|
||||
options: ["string", "text", "number", "select", "date", "boolean"].map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: "b_canlist",
|
||||
title: "列表",
|
||||
width: 64,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
editor: "checkbox",
|
||||
},
|
||||
{
|
||||
dataIndex: "b_canform",
|
||||
title: "表单",
|
||||
width: 64,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
editor: {
|
||||
type: "checkbox",
|
||||
onChange: (value, row) => {
|
||||
if (value === 0) row.b_must = 0;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: "b_must",
|
||||
title: "必填",
|
||||
width: 64,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
editor: { type: "checkbox", disabled: (row) => row.b_canform !== 1 },
|
||||
},
|
||||
{
|
||||
dataIndex: "b_canquery",
|
||||
title: "查询",
|
||||
width: 64,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
editor: "checkbox",
|
||||
},
|
||||
{
|
||||
dataIndex: "b_readonly",
|
||||
title: "只读",
|
||||
width: 64,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
editor: "checkbox",
|
||||
},
|
||||
{ dataIndex: "b_width", title: "宽度", width: 90, editor: { type: "number", min: 1 } },
|
||||
{ dataIndex: "b_xh", title: "顺序", width: 80, editor: { type: "number", min: 0 } },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-pane">
|
||||
<div class="toolbar">
|
||||
<a-button type="primary" :loading="syncing" @click="syncFields">
|
||||
<template #icon><RefreshCw /></template>
|
||||
同步字段
|
||||
</a-button>
|
||||
</div>
|
||||
<FmsTable
|
||||
class="sub-table"
|
||||
row-key="b_id"
|
||||
v-model:columns="columns"
|
||||
:data-source="fields"
|
||||
:active-row-key="activeRowKey"
|
||||
@row-click="onRowClick"
|
||||
/>
|
||||
<ModuleTranslationEditor ref="translationEditorRef" v-model:translations="translations" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-pane {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 10px 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sub-table {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { RefreshCw } from "@lucide/vue";
|
||||
import MetadataTablePanel from "./MetadataTablePanel.vue";
|
||||
|
||||
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 })),
|
||||
},
|
||||
},
|
||||
{ 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" },
|
||||
]);
|
||||
</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>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<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,146 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { Plus, Trash2 } from "@lucide/vue";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
import { createTempId } from "@/utils/tempId";
|
||||
import { createI18nCellRenderer } from "./createI18nCellRenderer";
|
||||
import ModuleTranslationEditor from "./ModuleTranslationEditor.vue";
|
||||
|
||||
const props = defineProps({
|
||||
moduleId: { type: [String, Number], default: "" },
|
||||
moduleCode: { type: String, default: "" },
|
||||
});
|
||||
|
||||
const powers = defineModel("powers", { type: Array, default: () => [] });
|
||||
const translations = defineModel("translations", { type: Object, default: () => ({}) });
|
||||
const selectedRowKeys = ref([]);
|
||||
// 当前编辑行 key;点击某行进入单行编辑,再次点击退出。
|
||||
const activeRowKey = ref(null);
|
||||
const translationEditorRef = ref(null);
|
||||
|
||||
const renderI18nCell = createI18nCellRenderer({
|
||||
activeRowKey,
|
||||
translationEditorRef,
|
||||
});
|
||||
|
||||
function onRowClick(_ev, row) {
|
||||
const key = String(row.b_id);
|
||||
activeRowKey.value = activeRowKey.value === key ? null : key;
|
||||
}
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys;
|
||||
},
|
||||
}));
|
||||
|
||||
watch(
|
||||
() => powers.value.map((item) => String(item.b_id)),
|
||||
(keys) => {
|
||||
const available = new Set(keys);
|
||||
selectedRowKeys.value = selectedRowKeys.value.filter((key) => available.has(String(key)));
|
||||
},
|
||||
);
|
||||
|
||||
function nextPowerCode() {
|
||||
const existing = new Set(powers.value.map((item) => item.b_code));
|
||||
let index = 1;
|
||||
while (existing.has(`new_${index}`)) index += 1;
|
||||
return `new_${index}`;
|
||||
}
|
||||
|
||||
function onAdd() {
|
||||
if (!props.moduleId) return;
|
||||
const code = nextPowerCode();
|
||||
powers.value = [
|
||||
...powers.value,
|
||||
{
|
||||
b_id: createTempId(),
|
||||
b_module_id: props.moduleId,
|
||||
b_code: code,
|
||||
b_name: "新权限",
|
||||
b_i18n: props.moduleCode ? `power.${props.moduleCode}.${code}` : null,
|
||||
b_canuse: 1,
|
||||
b_xh: (powers.value.length + 1) * 10,
|
||||
},
|
||||
];
|
||||
activeRowKey.value = powers.value[powers.value.length - 1].b_id;
|
||||
}
|
||||
|
||||
function onBatchDelete() {
|
||||
const selected = new Set(selectedRowKeys.value.map(String));
|
||||
powers.value = powers.value.filter((item) => !selected.has(String(item.b_id)));
|
||||
selectedRowKeys.value = [];
|
||||
if (activeRowKey.value && selected.has(String(activeRowKey.value))) {
|
||||
activeRowKey.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
const columns = ref([
|
||||
{
|
||||
dataIndex: "b_i18n",
|
||||
title: "多语言键",
|
||||
width: 250,
|
||||
customCell: renderI18nCell,
|
||||
},
|
||||
{ dataIndex: "b_code", title: "权限代码", width: 180, editor: { maxlength: 50 } },
|
||||
{ dataIndex: "b_name", title: "名称", width: 180, editor: { maxlength: 100 } },
|
||||
{
|
||||
dataIndex: "b_canuse",
|
||||
title: "启用",
|
||||
width: 74,
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
editor: "checkbox",
|
||||
},
|
||||
{ dataIndex: "b_xh", title: "顺序", width: 90, editor: { type: "number", min: 0 } },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="table-pane">
|
||||
<div class="toolbar">
|
||||
<a-button type="primary" @click="onAdd">
|
||||
<template #icon><Plus /></template>
|
||||
新增
|
||||
</a-button>
|
||||
<a-button danger :disabled="!selectedRowKeys.length" @click="onBatchDelete">
|
||||
<template #icon><Trash2 /></template>
|
||||
批量删除
|
||||
</a-button>
|
||||
</div>
|
||||
<FmsTable
|
||||
class="sub-table"
|
||||
row-key="b_id"
|
||||
v-model:columns="columns"
|
||||
:data-source="powers"
|
||||
:row-selection="rowSelection"
|
||||
:active-row-key="activeRowKey"
|
||||
@row-click="onRowClick"
|
||||
/>
|
||||
<ModuleTranslationEditor ref="translationEditorRef" v-model:translations="translations" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table-pane {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.sub-table {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup>
|
||||
import { createTempId } from "@/utils/tempId";
|
||||
import MetadataTablePanel from "./MetadataTablePanel.vue";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
function addPower() {
|
||||
powers.value.push({
|
||||
b_id: createTempId(),
|
||||
b_code: "",
|
||||
b_name: "新操作",
|
||||
b_i18n: null,
|
||||
b_xh: Math.max(0, ...powers.value.map((item) => Number(item.b_xh) || 0)) + 10,
|
||||
b_canuse: 1,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MetadataTablePanel
|
||||
v-model:rows="powers"
|
||||
title="模块操作权限"
|
||||
add-text="新增操作"
|
||||
:columns="columns"
|
||||
@add="addPower"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<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,87 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, reactive } from "vue";
|
||||
import { App } from "antdv-next";
|
||||
import { Languages } from "@lucide/vue";
|
||||
import { I18N_LOCALES, isValidI18nKey } from "@/utils/i18n";
|
||||
|
||||
const props = defineProps({
|
||||
i18nKey: { type: String, default: "" },
|
||||
disabled: { type: Boolean, default: false },
|
||||
// 默认语言(由 b_name 承载),不在此编辑器中维护,避免两份中文名互相脱节。
|
||||
defaultLocale: { type: String, default: "zh-CN" },
|
||||
// 默认语言对应的名称(如 b_name 的中文名),仅展示用,帮助辨识所属对象。
|
||||
defaultName: { type: String, default: "" },
|
||||
});
|
||||
const translations = defineModel("translations", { type: Object, default: () => ({}) });
|
||||
const { message } = App.useApp();
|
||||
const dialog = reactive({ open: false, key: "", name: "", values: {} });
|
||||
|
||||
// 仅维护非默认语言;默认语言(zh-CN)由对应实体的 b_name 字段承载。
|
||||
const editableLocales = computed(() =>
|
||||
I18N_LOCALES.filter((locale) => locale.value !== props.defaultLocale),
|
||||
);
|
||||
const defaultLocaleLabel = computed(
|
||||
() =>
|
||||
I18N_LOCALES.find((locale) => locale.value === props.defaultLocale)?.label ||
|
||||
props.defaultLocale,
|
||||
);
|
||||
|
||||
// 支持两种打开方式:
|
||||
// 1) 自带按钮点击:使用 props.i18nKey / props.defaultName
|
||||
// 2) 表格内按行打开:传入 key / name(如字段、权限的某行)
|
||||
function openEditor(forceKey, forceName) {
|
||||
const key = String(forceKey ?? props.i18nKey).trim();
|
||||
const name = forceName ?? props.defaultName;
|
||||
if (!isValidI18nKey(key)) {
|
||||
message.warning("请先填写正确的多语言键");
|
||||
return;
|
||||
}
|
||||
dialog.key = key;
|
||||
dialog.name = name || "";
|
||||
dialog.values = Object.fromEntries(
|
||||
editableLocales.value.map((locale) => [
|
||||
locale.value,
|
||||
translations.value[key]?.[locale.value] || "",
|
||||
]),
|
||||
);
|
||||
dialog.open = true;
|
||||
}
|
||||
|
||||
defineExpose({ open: openEditor });
|
||||
|
||||
function saveTranslations() {
|
||||
// 整条 key 只写非默认语言,顺带清掉历史残留的默认语言翻译,杜绝覆盖 b_name。
|
||||
translations.value = {
|
||||
...translations.value,
|
||||
[dialog.key]: { ...dialog.values },
|
||||
};
|
||||
dialog.open = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-button v-if="i18nKey" :disabled="disabled" @click="openEditor">
|
||||
<template #icon><Languages /></template>
|
||||
多语言
|
||||
</a-button>
|
||||
|
||||
<a-modal
|
||||
v-model:open="dialog.open"
|
||||
title="更新多语言"
|
||||
ok-text="更新"
|
||||
cancel-text="取消"
|
||||
@ok="saveTranslations"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="多语言键">
|
||||
<a-input :value="dialog.key" readonly />
|
||||
</a-form-item>
|
||||
<a-form-item :label="`${defaultLocaleLabel}(默认语言)`">
|
||||
<a-input :value="dialog.name" readonly />
|
||||
</a-form-item>
|
||||
<a-form-item v-for="locale in editableLocales" :key="locale.value" :label="locale.label">
|
||||
<a-input v-model:value="dialog.values[locale.value]" :maxlength="500" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<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>
|
||||
@@ -64,8 +64,9 @@ const contextMenu = computed(() => {
|
||||
const node = contextNode.value;
|
||||
return {
|
||||
items: [
|
||||
{ key: "module", label: "新增模块", disabled: !props.canCreate },
|
||||
{ key: "directory", label: "新增目录", disabled: !props.canCreate },
|
||||
{ key: "page", label: "新增页面", disabled: !props.canCreate },
|
||||
{ key: "data", label: "新增数据模块", disabled: !props.canCreate },
|
||||
{ type: "divider" },
|
||||
{
|
||||
key: "delete",
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import { h } from "vue";
|
||||
import { Input } from "antdv-next";
|
||||
import { Languages } from "@lucide/vue";
|
||||
|
||||
const cellStyle = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
width: "100%",
|
||||
};
|
||||
const inputStyle = { flex: "1 1 auto", minWidth: 0 };
|
||||
const textStyle = {
|
||||
flex: "1 1 auto",
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
};
|
||||
const iconStyle = {
|
||||
display: "inline-flex",
|
||||
flex: "none",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
cursor: "pointer",
|
||||
color: "var(--ant-color-primary, #1677ff)",
|
||||
};
|
||||
|
||||
export function createI18nCellRenderer({ activeRowKey, translationEditorRef }) {
|
||||
return function renderI18nCell({ row }) {
|
||||
const editing = activeRowKey.value != null && String(row.b_id) === String(activeRowKey.value);
|
||||
|
||||
return h("div", { style: cellStyle }, [
|
||||
editing
|
||||
? h("div", { style: inputStyle }, [
|
||||
h(Input, {
|
||||
value: row.b_i18n,
|
||||
size: "small",
|
||||
maxlength: 150,
|
||||
allowClear: true,
|
||||
style: { width: "100%" },
|
||||
onClick: (event) => event.stopPropagation(),
|
||||
"onUpdate:value": (value) => {
|
||||
row.b_i18n = value;
|
||||
},
|
||||
}),
|
||||
])
|
||||
: h("span", { style: textStyle }, row.b_i18n ?? ""),
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
style: iconStyle,
|
||||
title: "编辑翻译",
|
||||
onClick: (event) => {
|
||||
event.stopPropagation();
|
||||
translationEditorRef.value?.open(row.b_i18n, row.b_name);
|
||||
},
|
||||
},
|
||||
[h(Languages, { size: 16 })],
|
||||
),
|
||||
]);
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user