20260722222510

This commit is contained in:
oneao committed 2026-07-22 22:25:10 +08:00
1 parent d04fad24ed
commit 59b3ccfb1c
36 files changed
+2753 -3064

No files matched your search

+55 -4
View File
@@ -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 {
+16 -5
View File
@@ -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",
},
},
{
+14
View File
@@ -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,
+55
View File
@@ -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>
@@ -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