20260811165111

This commit is contained in:
oneao committed 2026-08-11 16:51:11 +08:00
1 parent 5adbf59425
commit ce78653207
34 files changed
+3793 -366

No files matched your search

+148 -2
View File
@@ -1,13 +1,159 @@
<!doctype html>
<html lang="en">
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fms-vue</title>
<script>
try {
if (localStorage.getItem("fms-dark") === "true") {
document.documentElement.classList.add("dark");
}
} catch {
// Storage may be unavailable in restricted browser contexts.
}
</script>
<style>
:root {
--boot-background: #ffffff;
--boot-foreground: #171717;
--boot-border: #e5e5e5;
--boot-error: #dc2626;
--boot-accent: #863bff;
}
.dark {
--boot-background: #171717;
--boot-foreground: #fafafa;
--boot-border: #404040;
--boot-error: #f87171;
--boot-accent: #a774ff;
}
html,
body,
#app {
width: 100%;
height: 100%;
margin: 0;
}
.app-boot {
position: fixed;
inset: 0;
display: grid;
place-items: center;
color: var(--boot-foreground);
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
letter-spacing: 0;
background: var(--boot-background);
}
.app-boot__content {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.app-boot__mark {
position: relative;
display: grid;
width: 72px;
height: 72px;
place-items: center;
}
.app-boot__spinner {
position: absolute;
inset: 0;
box-sizing: border-box;
border: 2px solid var(--boot-border);
border-top-color: var(--boot-accent);
border-right-color: var(--boot-accent);
border-radius: 50%;
animation: app-boot-spin 900ms linear infinite;
}
.app-boot__logo {
width: 38px;
height: 38px;
}
.app-boot__message {
max-width: 320px;
margin: 0;
color: var(--boot-error);
font-size: 13px;
line-height: 1.5;
text-align: center;
}
.app-boot__retry {
height: 32px;
padding: 0 12px;
color: var(--boot-foreground);
font: inherit;
font-size: 13px;
cursor: pointer;
background: var(--boot-background);
border: 1px solid var(--boot-border);
border-radius: 6px;
}
.app-boot__retry:hover {
background: color-mix(in srgb, var(--boot-foreground) 6%, var(--boot-background));
}
.app-boot--error .app-boot__spinner {
display: none;
}
[hidden] {
display: none !important;
}
@keyframes app-boot-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.app-boot__spinner {
animation-duration: 1400ms;
}
}
</style>
</head>
<body>
<div id="app"></div>
<div id="app">
<div
id="app-boot"
class="app-boot"
role="status"
aria-label="应用加载中"
aria-live="polite"
aria-busy="true"
>
<div class="app-boot__content">
<div class="app-boot__mark" aria-hidden="true">
<span class="app-boot__spinner" aria-hidden="true"></span>
<img class="app-boot__logo" src="/favicon.svg" alt="" />
</div>
<p id="app-boot-message" class="app-boot__message" hidden></p>
<button id="app-boot-retry" class="app-boot__retry" type="button" hidden>重新加载</button>
</div>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+8
View File
@@ -1,8 +1,16 @@
<script setup lang="ts">
import { defineAsyncComponent } from "vue";
import { Toaster } from "@/components/ui/sonner";
import ConfirmDialog from "@/components/ConfirmDialog.vue";
const FpsMonitor = import.meta.env.DEV
? defineAsyncComponent(() => import("@/components/FpsMonitor.vue"))
: null;
</script>
<template>
<router-view />
<Toaster position="top-center" />
<ConfirmDialog />
<FpsMonitor v-if="FpsMonitor" />
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { useConfirm, handleConfirm } from "@/composables/useConfirm";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
const { state } = useConfirm();
function onOpenChange(open: boolean) {
if (!open) handleConfirm(false);
}
</script>
<template>
<Dialog :open="state.open" @update:open="onOpenChange">
<DialogContent class="max-w-sm" :show-close-button="false">
<DialogHeader>
<DialogTitle>{{ state.title }}</DialogTitle>
<DialogDescription v-if="state.description">{{ state.description }}</DialogDescription>
</DialogHeader>
<DialogFooter class="gap-2 sm:gap-2">
<Button variant="outline" @click="handleConfirm(false)">{{ state.cancelText }}</Button>
<Button :variant="state.danger ? 'destructive' : 'default'" @click="handleConfirm(true)">
{{ state.confirmText }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -0,0 +1,27 @@
<script setup lang="ts">
// 加载遮罩层:覆盖在内容区域上方,显示加载状态。
withDefaults(
defineProps<{
loading?: boolean;
text?: string;
}>(),
{
loading: false,
text: "加载中…",
},
);
</script>
<template>
<div
v-if="loading"
class="pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-background/70 backdrop-blur-sm"
>
<div class="flex flex-col items-center gap-3">
<span
class="h-8 w-8 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-primary"
/>
<span class="text-sm text-muted-foreground">{{ text }}</span>
</div>
</div>
</template>
@@ -55,6 +55,7 @@ watch(
const virtualRoot = computed<TreeNode>(() => ({
key: props.rootKey,
title: props.rootTitle,
isFmsRoot: true,
children: props.treeData,
}));
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { RotateCcw } from "@lucide/vue";
import { Button } from "@/components/ui/button";
defineOptions({ inheritAttrs: false });
const props = withDefaults(
defineProps<{
sampleInterval?: number;
historySize?: number;
baseFps?: number;
}>(),
{
sampleInterval: 500,
historySize: 48,
baseFps: 60,
},
);
const currentFps = ref(0);
const averageFps = ref(0);
const minimumFps = ref<number | null>(null);
const history = ref<number[]>([]);
const normalizedSampleInterval = computed(() => Math.max(100, props.sampleInterval));
const normalizedHistorySize = computed(() => Math.max(1, Math.floor(props.historySize)));
const normalizedBaseFps = computed(() => Math.max(1, props.baseFps));
let animationFrameId = 0;
let sampleFrames = 0;
let sampleStartedAt = 0;
let totalFrames = 0;
let totalTime = 0;
function resetSampleWindow() {
sampleFrames = 0;
sampleStartedAt = performance.now();
}
function tick(now: number) {
sampleFrames += 1;
totalFrames += 1;
const elapsed = now - sampleStartedAt;
if (elapsed >= normalizedSampleInterval.value) {
const fps = Math.round((sampleFrames * 1000) / elapsed);
currentFps.value = fps;
minimumFps.value = minimumFps.value == null ? fps : Math.min(minimumFps.value, fps);
totalTime += elapsed;
averageFps.value = Math.round((totalFrames * 1000) / totalTime);
history.value = [...history.value, fps].slice(-normalizedHistorySize.value);
sampleFrames = 0;
sampleStartedAt = now;
}
animationFrameId = requestAnimationFrame(tick);
}
function reset() {
currentFps.value = 0;
averageFps.value = 0;
minimumFps.value = null;
history.value = [];
totalFrames = 0;
totalTime = 0;
resetSampleWindow();
}
function handleVisibilityChange() {
if (!document.hidden) resetSampleWindow();
}
onMounted(() => {
resetSampleWindow();
document.addEventListener("visibilitychange", handleVisibilityChange);
animationFrameId = requestAnimationFrame(tick);
});
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
function getFpsColor(fps: number) {
if (fps >= normalizedBaseFps.value * (5 / 6)) return "var(--chart-2)";
if (fps >= normalizedBaseFps.value / 2) return "var(--chart-4)";
return "var(--destructive)";
}
const currentFpsColor = computed(() => getFpsColor(currentFps.value));
function getBarStyle(fps: number) {
const height = Math.max(8, Math.min(100, (fps / normalizedBaseFps.value) * 100));
return {
height: `${height}%`,
backgroundColor: getFpsColor(fps),
};
}
</script>
<template>
<aside class="fps-monitor" aria-label="FPS 性能监控" v-bind="$attrs">
<div class="fps-monitor__header">
<span class="fps-monitor__label">FPS</span>
<strong class="fps-monitor__current" :style="{ color: currentFpsColor }">
{{ currentFps || "--" }}
</strong>
<Button
variant="ghost"
size="icon-xs"
type="button"
title="重置统计"
aria-label="重置 FPS 统计"
@click="reset"
>
<RotateCcw />
</Button>
</div>
<div class="fps-monitor__chart" aria-hidden="true">
<span
v-for="(fps, index) in history"
:key="index"
class="fps-monitor__bar"
:style="getBarStyle(fps)"
/>
</div>
<div class="fps-monitor__summary">
<span
>avg <b>{{ averageFps || "--" }}</b></span
>
<span
>min <b>{{ minimumFps ?? "--" }}</b></span
>
</div>
</aside>
</template>
<style scoped lang="scss">
.fps-monitor {
position: fixed;
top: 8px;
right: 8px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 6px;
width: 132px;
padding: 8px 10px;
color: var(--foreground);
font-size: 12px;
line-height: 1.4;
letter-spacing: 0;
user-select: none;
background: var(--background);
border: 1px solid var(--border);
border-radius: var(--radius-md);
box-shadow: 0 2px 8px color-mix(in srgb, var(--foreground) 10%, transparent);
}
.fps-monitor__header {
display: flex;
align-items: center;
gap: 6px;
min-height: 24px;
}
.fps-monitor__label {
color: var(--muted-foreground);
font-weight: 600;
}
.fps-monitor__current {
flex: 1;
font-size: 18px;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.fps-monitor__chart {
display: flex;
align-items: flex-end;
gap: 1px;
height: 32px;
overflow: hidden;
}
.fps-monitor__bar {
flex: 1;
min-width: 0;
border-radius: 1px;
}
.fps-monitor__summary {
display: flex;
justify-content: space-between;
color: var(--muted-foreground);
}
.fps-monitor__summary b {
color: var(--foreground);
font-variant-numeric: tabular-nums;
}
</style>
@@ -237,6 +237,7 @@ const displayColumns = computed(() => {
return cols.map((column) => ({
...column,
className: [column.className, column.editor && "fms-editor-cell"].filter(Boolean).join(" "),
headerAlign: getHeaderAlign(column),
}));
});
@@ -273,15 +274,12 @@ const hasBottomGap = computed(() => {
return props.dataSource.length * rowHeight < bodyHeight - 1;
});
const customVars = computed(() => {
// 线条只覆盖真实数据行;数据铺满视口时不绘制最后一行,避免与外框底线叠加。
const lineRowCount = Math.max(props.dataSource.length - (hasBottomGap.value ? 0 : 1), 0);
return {
"--header-row-height": `${headerRowHeight}px`,
"--row-height": `${rowHeight}px`,
"--footer-row-height": "36px",
"--th-bgc": "var(--background)",
"--tf-bgc": "var(--background)",
"--fms-data-line-height": `${lineRowCount * rowHeight}px`,
};
});
@@ -374,8 +372,8 @@ function commitPendingEditing() {
(item) => item === target.row || String(getRowKey(item)) === String(getRowKey(target.row)),
);
if (row) {
activateEditing(row, target.focusCol);
tableRef.value?.setCurrentRow?.(row, { silent: true });
activateEditing(row, target.focusCol);
lastEditActivateAt = performance.now();
}
}
@@ -446,6 +444,7 @@ function handleAreaSelectionChange(ranges) {
if (isRepeatNavigation) {
pendingEditingTarget = { row, focusCol };
clearActiveEditing();
// 长按期间由选区高亮跟随目标;松键时再同步活动行,避免每次重复按键重渲染整表。
} else {
const now = performance.now();
if (now - lastEditActivateAt >= 150) {
@@ -527,7 +526,12 @@ defineExpose({
</script>
<template>
<div ref="rootRef" class="fms-table" @keydown.capture="handleKeydownCapture">
<div
ref="rootRef"
class="fms-table"
:class="{ 'fms-table--bordered': bordered }"
@keydown.capture="handleKeydownCapture"
>
<StkTable
ref="tableRef"
class="fms-table__inner"
@@ -574,9 +578,11 @@ defineExpose({
.fms-table {
width: 100%;
height: 100%;
overflow: hidden;
}
.fms-table--bordered {
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.fms-table__inner {
@@ -609,7 +615,6 @@ defineExpose({
/*
* 外层绘制表格边框,表体只保留横向分割线。
* STK 主表继续使用 fit-content 保持配置列宽,独立装饰层只负责补齐右侧空白区域的横线。
*/
.fms-table__inner.stk-table {
th,
@@ -629,29 +634,6 @@ defineExpose({
}
}
}
// 不改变表格列宽,单独用装饰层把行分割线延伸到容器右侧空白区域。
.stk-table-scroll-container {
position: relative;
min-width: 100%;
&::before {
position: absolute;
top: calc(var(--header-row-height) - 1px);
left: 0;
width: 100%;
height: calc(var(--fms-data-line-height) + 1px);
pointer-events: none;
content: "";
background-image: repeating-linear-gradient(
to bottom,
var(--border) 0,
var(--border) 1px,
transparent 1px,
transparent var(--row-height)
);
}
}
}
.fms-checkbox {
@@ -800,7 +782,7 @@ defineExpose({
white-space: nowrap;
}
.fms-table__inner td:has(> .fms-cell-editor-root) {
.fms-table__inner td.fms-editor-cell {
padding-inline: 3px;
}
@@ -21,6 +21,15 @@ import { cn } from "@/lib/utils";
import { createEditableCell } from "./createEditableCellRenderer";
const stop = (event) => event.stopPropagation();
const actionButtonClass = cn(buttonVariants({ variant: "ghost", size: "xs" }), "fms-action-btn");
const dangerActionButtonClass = cn(
actionButtonClass,
"text-destructive hover:bg-destructive/10 hover:text-destructive",
);
const moreActionButtonClass = cn(
buttonVariants({ variant: "ghost", size: "xs" }),
"fms-action-btn fms-action-more",
);
function resolveActionState(state, row) {
return typeof state === "function" ? Boolean(state(row)) : Boolean(state);
@@ -30,7 +39,7 @@ function isActionVisible(action, row) {
return action.visible === undefined || resolveActionState(action.visible, row);
}
function actionButton(action, row, triggerAction, className = "fms-action-btn") {
function actionButton(action, row, triggerAction) {
const disabled = resolveActionState(action.disabled, row);
const danger = action.danger || action.type === "danger" || action.type === "delete";
const content = [];
@@ -40,11 +49,7 @@ function actionButton(action, row, triggerAction, className = "fms-action-btn")
"button",
{
type: "button",
class: cn(
buttonVariants({ variant: "ghost", size: "xs" }),
danger && "text-destructive hover:bg-destructive/10 hover:text-destructive",
className,
),
class: danger ? dangerActionButtonClass : actionButtonClass,
title: action.title || action.label,
disabled,
onMousedown: stop,
@@ -82,7 +87,10 @@ function createConfirmDialog(state, close, confirm) {
h(
AlertDialogAction,
{
class: config.okType === "danger" ? buttonVariants({ variant: "destructive" }) : undefined,
class:
config.okType === "danger"
? buttonVariants({ variant: "destructive" })
: undefined,
onClick: () => {
const pending = state.value;
close();
@@ -133,11 +141,10 @@ function createActionsCell(actions) {
"button",
{
type: "button",
class: cn(
buttonVariants({ variant: "ghost", size: "xs" }),
"fms-action-btn fms-action-more",
class: [
moreActionButtonClass,
openRow.value === row && "bg-accent text-accent-foreground",
),
],
title: moreAction.title || "更多操作",
"aria-label": moreAction.title || "更多操作",
onMousedown: stop,
@@ -212,16 +219,6 @@ export function resolveCustomCell(col, editingContext) {
if (customCell) return { ...col, customCell };
}
// 纯展示列:保持文本可选中,同时通过表格选区激活行编辑。
if (col.dataIndex) {
return {
...col,
customCell: ({ row }) => {
const value = row[col.dataIndex];
const content = value ?? "";
return h("span", { class: "fms-cell-display", title: String(content) }, content);
},
};
}
// 纯展示列使用 StkTable 原生渲染,避免虚拟滚动时重复创建函数组件。
return col;
}
@@ -0,0 +1,50 @@
import { reactive } from "vue";
interface ConfirmOptions {
title?: string;
description?: string;
confirmText?: string;
cancelText?: string;
danger?: boolean;
}
interface ConfirmState extends Required<Omit<ConfirmOptions, "danger">> {
open: boolean;
danger: boolean;
resolve: ((ok: boolean) => void) | null;
}
const state = reactive<ConfirmState>({
open: false,
title: "确认操作",
description: "",
confirmText: "确定",
cancelText: "取消",
danger: false,
resolve: null,
});
export function useConfirm() {
function confirm(options: ConfirmOptions | string): Promise<boolean> {
const opts = typeof options === "string" ? { description: options } : options;
state.title = opts.title ?? "确认操作";
state.description = opts.description ?? "";
state.confirmText = opts.confirmText ?? "确定";
state.cancelText = opts.cancelText ?? "取消";
state.danger = opts.danger ?? false;
state.open = true;
return new Promise<boolean>((resolve) => {
state.resolve = resolve;
});
}
return { confirm, state };
}
export function handleConfirm(ok: boolean) {
if (!state.resolve) return;
state.open = false;
const resolve = state.resolve;
state.resolve = null;
resolve(ok);
}
@@ -110,7 +110,7 @@ function refreshTab(key: string) {
<template>
<div
v-if="tabs.length"
class="flex h-10 w-full min-w-0 shrink-0 items-center gap-0.5 overflow-hidden px-2"
class="flex h-10 w-full min-w-0 shrink-0 items-center gap-0.5 overflow-hidden px-4"
>
<!-- Left scroll button -->
<Button
+28 -1
View File
@@ -16,4 +16,31 @@ const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
app.use(pinia);
app.use(router);
app.mount("#app");
function showBootstrapError(error: unknown) {
console.error("Application bootstrap failed", error);
const boot = document.querySelector<HTMLElement>("#app-boot");
const message = document.querySelector<HTMLElement>("#app-boot-message");
const retry = document.querySelector<HTMLButtonElement>("#app-boot-retry");
if (!boot || !message || !retry) return;
boot.classList.add("app-boot--error");
boot.setAttribute("aria-busy", "false");
boot.setAttribute("aria-label", "应用加载失败");
message.textContent = "应用加载失败,请检查网络后重试";
message.hidden = false;
retry.hidden = false;
retry.addEventListener("click", () => window.location.reload(), { once: true });
}
async function bootstrap() {
try {
await router.isReady();
app.mount("#app");
} catch (error) {
showBootstrapError(error);
}
}
void bootstrap();
+8
View File
@@ -121,6 +121,14 @@
outline-color: color-mix(in srgb, var(--ring) 50%, transparent);
}
/* 浏览器默认不会给 button / 勾选框自动指针,统一补上,避免各处漏加 cursor-pointer。
放在 base 层,仍会被具体元素上的 cursor-* 工具类(如 disabled:cursor-default)覆盖。 */
button:not(:disabled),
[role="button"]:not(:disabled),
label.fms-checkbox:not(.disabled) {
cursor: pointer;
}
body {
background-color: var(--background);
color: var(--foreground);
+176
View File
@@ -0,0 +1,176 @@
export type ModuleType = "directory" | "page" | "data_category" | "data";
export const MODULE_TYPE_LABELS: Record<ModuleType, string> = {
directory: "目录",
page: "页面",
data_category: "数据分类",
data: "数据",
};
/** 模块主表 s_module */
export interface ModuleData {
b_id: string;
b_code: string;
b_name: string;
b_module_type: ModuleType;
b_parent_id: string | null;
b_i18n: string | null;
b_xh: number;
b_icon: string | null;
b_route: string | null;
b_viewtable: string | null;
b_savetable: string | null;
b_keyfield: string | null;
b_orderfield: string | null;
b_canmenu: number;
b_canuse: number;
[key: string]: unknown;
}
/** 字段定义 s_module_field */
export interface ModuleField {
b_id: string;
b_module_id: string;
b_field: string;
b_name: string;
b_i18n: string | null;
b_type: string;
b_default_value: string | null;
b_options: string | null;
b_canuse: number;
[key: string]: unknown;
}
/** 字段分组 s_module_field_group */
export interface FieldGroup {
b_id: string;
b_module_id: string;
b_name: string;
b_i18n: string | null;
b_xh: number;
b_canuse: number;
b_parent_id: string | null;
[key: string]: unknown;
}
/** 列表配置 s_module_field_list */
export interface ListFieldConfig {
b_id: string;
b_module_id: string;
b_field_id: string;
b_group_id: string | null;
b_title: string | null;
b_i18n: string | null;
b_visible: number;
b_xh: number;
b_width: number;
b_sortable: number;
[key: string]: unknown;
}
/** 编辑配置 s_module_field_edit */
export interface EditFieldConfig {
b_id: string;
b_module_id: string;
b_field_id: string;
b_required: number;
b_readonly: number;
b_disabled: number;
b_visible: number;
b_group_id: string | null;
b_xh: number;
[key: string]: unknown;
}
/** 查询配置 s_module_field_query */
export interface QueryFieldConfig {
b_id: string;
b_module_id: string;
b_field_id: string;
b_component: string;
b_operator: string;
b_default_value: string | null;
b_xh: number;
b_canuse: number;
[key: string]: unknown;
}
/** 自动编码 s_module_auto_code */
export interface AutoCodeConfig {
b_id: string;
b_module_id: string;
b_field_id: string | null;
b_prefix: string;
b_date_format: string;
b_separator: string;
b_sequence_width: number;
b_reset_type: string;
b_start_value: number;
b_current_period: string | null;
b_current_value: number;
b_canuse: number;
[key: string]: unknown;
}
/** 操作权限 s_module_power */
export interface ModulePower {
b_id: string;
b_module_id: string;
b_code: string;
b_name: string;
b_i18n: string | null;
b_xh: number;
b_canuse: number;
[key: string]: unknown;
}
/** 多语言翻译行(透视结构) */
export interface I18nRow {
_id?: string;
b_key: string;
_managedId?: string;
_syncInjected?: boolean;
_defaultValue?: string;
_localeIds?: Record<string, string>;
[locale: string]: unknown;
}
/** 受管多语言项来源描述 */
export interface ManagedI18nItem {
id?: string;
key: string;
sourceKey?: string | null;
defaultValue?: string;
managed: boolean;
}
/** 一套完整的模块配置快照 */
export interface ModuleConfiguration {
module: ModuleData | null;
fields: ModuleField[];
groups: FieldGroup[];
listConfig: ListFieldConfig[];
editConfig: EditFieldConfig[];
queryConfig: QueryFieldConfig[];
autoCode: AutoCodeConfig[];
powers: ModulePower[];
}
export type DataSection =
| "fields"
| "groups"
| "listConfig"
| "editConfig"
| "queryConfig"
| "autoCode"
| "powers";
export type SectionKey =
| "basic"
| "fields"
| "list"
| "edit"
| "query"
| "auto-code"
| "powers"
| "i18n";
+96
View File
@@ -0,0 +1,96 @@
import { nextIdApi } from "@/services/api";
/** 深拷贝任意 JSON 可序列化值。 */
export function cloneData<T>(value: T): T {
return JSON.parse(JSON.stringify(value));
}
export interface RowDiff<T> {
inserts: T[];
updates: T[];
deletes: { b_id: string }[];
}
/**
* 比较当前草稿与原始快照,返回新增/修改/删除三份变更列表。
* 以 b_id 为唯一标识(字符串比较),行内容用 JSON 序列化比对。
*/
export function diffRows<T extends { b_id: string }>(current: T[], previous: T[]): RowDiff<T> {
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 })),
};
}
/**
* 生成临时 ID 与真实雪花 ID 的映射:
* 扫描所有行组中形如 "-123" 的临时负数 ID,批量向后端申请真实 ID 并就地替换。
* 返回 临时ID → 真实ID 的映射表(供外键 remap 使用)。
*/
export async function allocateTemporaryIds(
rowGroups: Record<string, unknown>[][],
): Promise<Map<string, string>> {
const temporaryIds = [
...new Set(
rowGroups
.flat()
.map((row) => String(row?.b_id ?? ""))
.filter((id) => /^-\d+$/.test(id)),
),
];
if (!temporaryIds.length) return new Map();
const res = await nextIdApi(temporaryIds.length);
const ids = Array.isArray(res.data) ? res.data : [res.data];
const mapping = new Map<string, string>(
temporaryIds.map((id, index) => [id, String(ids[index])]),
);
rowGroups.flat().forEach((row) => {
const id = mapping.get(String(row?.b_id));
if (id) row.b_id = id;
});
return mapping;
}
/** 根据映射表,将 rows 中指定外键字段的临时 ID 替换为真实 ID。 */
export function remapForeignKeys(
rows: Record<string, unknown>[],
fields: string[],
mapping: Map<string, string>,
): void {
rows.forEach((row) => {
fields.forEach((field) => {
const value = mapping.get(String(row[field]));
if (value) row[field] = value;
});
});
}
/** 构造单个表的保存请求对象。 */
export function tableChange(
table: string,
changes: {
inserts?: unknown[];
updates?: unknown[];
deletes?: unknown[];
},
): import("@/services/api").SaveRequest {
return { table, key_field: "b_id", ...changes };
}
/** 判断某个保存请求是否包含任何变更。 */
export function hasChanges(request: import("@/services/api").SaveRequest): boolean {
return Boolean(request.inserts?.length || request.updates?.length || request.deletes?.length);
}
+156
View File
@@ -0,0 +1,156 @@
/** 校验模块编码:非空、长度 2-50、小写字母开头,仅含小写字母/数字/下划线。 */
export function validateModuleCode(code: unknown): string {
const value = String(code ?? "").trim();
if (!value) return "编码不能为空";
if (value.length < 2 || value.length > 50) return "编码长度需为 2-50 个字符";
if (!/^[a-z][a-z0-9_]*$/.test(value)) {
return "编码须以小写字母开头,且只能包含小写字母、数字和下划线";
}
return "";
}
/** 从 i18n key 中提取命名空间(第一个点之前的部分)。 */
export function getI18nNamespace(key: unknown): string {
return String(key || "").split(".", 1)[0] || "common";
}
/** 校验 i18n key 是否合法。 */
export function isValidI18nKey(key: unknown): boolean {
return /^[A-Za-z][A-Za-z0-9_.:-]{1,149}$/.test(String(key || ""));
}
/** 为 item(字段/权限等)生成受管 i18n key。 */
export function buildModuleItemI18nKey(
prefix: string,
moduleCode: unknown,
itemCode: unknown,
): string | null {
const normalizedModuleCode = String(moduleCode || "").trim();
const normalizedItemCode = String(itemCode || "").trim();
return normalizedModuleCode && normalizedItemCode
? `${prefix}.${normalizedModuleCode}.${normalizedItemCode}`
: null;
}
/** 为模块本身生成受管 i18n key(data 类型用 module 前缀,其余用 menu 前缀)。 */
export function buildModuleI18nKey(moduleType: unknown, moduleCode: unknown): string | null {
const normalizedModuleCode = String(moduleCode || "").trim();
if (!normalizedModuleCode) return null;
const prefix = moduleType === "data" ? "module" : "menu";
return `${prefix}.${normalizedModuleCode}`;
}
/**
* 同步子表行的受管 i18n key。返回是否有变更。
*/
export function syncModuleItemI18nKeys(
rows: Record<string, unknown>[] | undefined,
prefix: string,
moduleCode: unknown,
codeField: string,
): boolean {
let changed = false;
for (const row of rows || []) {
const key = buildModuleItemI18nKey(prefix, moduleCode, row?.[codeField]);
if (row.b_i18n === key) continue;
row.b_i18n = key;
changed = true;
}
return changed;
}
/** 将扁平 i18n 记录按 b_key 聚合成透视行(含各语言值及原始记录引用)。 */
export function groupI18nRows(
rows: { b_key: string; b_locale: string; b_value: unknown }[],
): Record<
string,
{ key: string; values: Record<string, unknown>; records: Record<string, unknown> }
> {
const groups: Record<
string,
{ key: string; values: Record<string, unknown>; records: Record<string, unknown> }
> = {};
rows.forEach((item) => {
const key = item.b_key;
groups[key] ||= { key, values: {}, records: {} };
groups[key].values[item.b_locale] = item.b_value;
groups[key].records[item.b_locale] = item;
});
return groups;
}
/**
* 将同步字段/权限生成的默认翻译条目并入透视行数组:
* - 已存在同 b_key 的行则补全其空语言值(不覆盖用户已填内容);
* - 否则新增一行;managedId 用于关联字段,使重命名字段时复用同一行。
* 返回新数组,不修改入参。
*/
export function mergeI18nDefaults(
rows: Record<string, unknown>[],
entries: { key: string; locale: string; value: unknown; managedId?: string }[],
): Record<string, unknown>[] {
const next = [...rows];
const byKey = new Map(next.map((r) => [String(r.b_key), r]));
const managedIds = new Set(
next
.map((r) => r._managedId)
.filter(Boolean)
.map(String),
);
for (const e of entries || []) {
if (!e.key) continue;
const existing = byKey.get(String(e.key));
if (existing) {
if (existing[e.locale] == null || existing[e.locale] === "") {
existing[e.locale] = e.value;
}
continue;
}
if (e.managedId && managedIds.has(String(e.managedId))) continue;
const row: Record<string, unknown> = {
_id: "i18n_" + Math.random().toString(36).slice(2, 10),
b_key: e.key,
_localeIds: {},
...(e.managedId ? { _managedId: String(e.managedId) } : {}),
...(e.managedId ? { _syncInjected: true } : {}),
[e.locale]: e.value,
};
next.push(row);
byKey.set(String(e.key), row);
if (e.managedId) managedIds.add(String(e.managedId));
}
return next;
}
/**
* 合并同 b_key 的重复透视行,保证一个键仅一行。
* 多行同键时保留各自非空的语言值,受管标记等取优先者。
* 返回新数组,不修改入参。
*/
export function consolidatePivotRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
const byKey = new Map<string, Record<string, unknown>>();
const merged: Record<string, unknown>[] = [];
for (const row of rows) {
const key = String(row.b_key || "");
const hit = byKey.get(key);
if (hit) {
for (const [k, v] of Object.entries(row)) {
if (k === "_id" || k === "_localeIds") continue;
if ((hit[k] == null || hit[k] === "") && v != null) hit[k] = v;
}
Object.assign(hit._localeIds || (hit._localeIds = {}), row._localeIds || {});
if (row._managedId && !hit._managedId) hit._managedId = row._managedId;
if (row._syncInjected && !hit._syncInjected) hit._syncInjected = row._syncInjected;
if (row._defaultValue != null && hit._defaultValue == null) {
hit._defaultValue = row._defaultValue;
}
if (row._sortType && !hit._sortType) hit._sortType = row._sortType;
} else {
byKey.set(key, row);
merged.push(row);
}
}
return merged;
}
+12
View File
@@ -0,0 +1,12 @@
// 生成本地临时 ID,用于尚未保存到后端的子表新增行。
// 使用负数以区别于后端生成的雪花 ID(恒为正),保存时后端会替换为真实 ID。
let sequence = 0;
/**
* 生成会话内唯一的临时 ID(负数字符串)。
* @returns 形如 "-1"、"-2" 的临时 ID。
*/
export function createTempId(): string {
sequence += 1;
return String(-sequence);
}
+58 -203
View File
@@ -1,24 +1,9 @@
<script setup lang="ts">
import { computed, nextTick, ref, shallowRef } from "vue";
import { toast } from "vue-sonner";
import {
CheckCircle2,
Copy,
Database,
Pencil,
Plus,
RefreshCw,
Rows3,
Search,
Trash2,
X,
} from "@lucide/vue";
import FmsAutoComplete from "@/components/FmsAutoComplete.vue";
import FmsSelect from "@/components/FmsSelect.vue";
import { Copy, Pencil, Plus, RefreshCw, Search, Trash2, X } from "@lucide/vue";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
@@ -39,7 +24,8 @@ type DemoRow = {
updatedAt: string;
};
const ROW_COUNT = 15;
const ROW_COUNT = 5000;
const DEMO_NAMES = ["客户资料", "订单状态", "库存预警", "合同归档", "发票登记"];
const OWNER_OPTIONS = [
{ label: "林晓", value: "lin-xiao" },
{ label: "陈默", value: "chen-mo" },
@@ -55,7 +41,7 @@ function buildRows(): DemoRow[] {
return Array.from({ length: ROW_COUNT }, (_, index) => ({
id: `R${String(index + 1).padStart(4, "0")}`,
code: `FMS-${String(index + 1).padStart(4, "0")}`,
name: ["客户资料", "订单状态", "库存预警", "合同归档", "发票登记"][index % 5] + ` ${index + 1}`,
name: `${DEMO_NAMES[index % DEMO_NAMES.length]} ${index + 1}`,
owner: OWNER_OPTIONS[index % OWNER_OPTIONS.length]!.value,
status: index % 7 === 0 ? 0 : 1,
enabled: index % 11 === 0 ? 0 : 1,
@@ -141,7 +127,6 @@ const rowSelection = computed(() => ({
},
}));
const enabledCount = computed(() => dataSource.value.filter((row) => row.enabled === 1).length);
const selectedCount = computed(() => selectedRowKeys.value.length);
function syncRows() {
@@ -235,204 +220,74 @@ function clearSelection() {
selectedRowKeys.value = [];
tableRef.value?.clearSelectedArea();
}
// 远程控件 demo
const localeCode = ref("");
const localeLabel = ref("");
const localeRecord = ref<Record<string, unknown> | null>(null);
const autoValue = ref("");
const autoRecord = ref<Record<string, unknown> | null>(null);
</script>
<template>
<main class="flex min-h-full flex-col gap-5 pb-2">
<header class="flex flex-wrap items-end justify-between gap-4 border-b pb-4">
<div class="min-w-0">
<div
class="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground"
>
<Database class="size-3.5" />
FmsTable Playground
</div>
<h1 class="text-2xl font-semibold tracking-tight">数据表格工作台</h1>
<p class="mt-1 max-w-2xl text-sm text-muted-foreground">
在真实数据场景下验证虚拟滚动、行选择、单元格编辑和行拖拽。
</p>
</div>
<main class="flex h-full min-h-[460px] flex-col gap-3">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" @click="clearSelection">
<X data-icon="inline-start" />
清空选择
<Button size="sm" @click="addRow">
<Plus data-icon="inline-start" />
新增行
</Button>
<Button variant="outline" size="sm" @click="regenerate">
<RefreshCw data-icon="inline-start" />
重新生成
</Button>
<Button variant="outline" size="sm" :disabled="!selectedCount" @click="editFirstSelected">
<Pencil data-icon="inline-start" />
编辑选中
</Button>
<Button size="sm" @click="regenerate">
<RefreshCw data-icon="inline-start" />
重新生成
<Button variant="ghost" size="sm" :disabled="!selectedCount" @click="clearSelection">
<X data-icon="inline-start" />
清空选择
</Button>
</div>
</header>
<section class="grid grid-cols-2 gap-3 sm:grid-cols-4" aria-label="表格统计">
<Card class="gap-2 py-3">
<CardHeader class="flex items-center justify-between px-4 pb-0 pt-0"
><CardTitle class="text-xs font-medium text-muted-foreground">总记录</CardTitle
><Rows3 class="size-4 text-muted-foreground"
/></CardHeader>
<CardContent class="px-4"
><div class="text-xl font-semibold tabular-nums">
{{ dataSource.length.toLocaleString() }}
</div></CardContent
>
</Card>
<Card class="gap-2 py-3">
<CardHeader class="flex items-center justify-between px-4 pb-0 pt-0"
><CardTitle class="text-xs font-medium text-muted-foreground">当前显示</CardTitle
><Search class="size-4 text-muted-foreground"
/></CardHeader>
<CardContent class="px-4"
><div class="text-xl font-semibold tabular-nums">
{{ filteredRows.length.toLocaleString() }}
</div></CardContent
>
</Card>
<Card class="gap-2 py-3">
<CardHeader class="flex items-center justify-between px-4 pb-0 pt-0"
><CardTitle class="text-xs font-medium text-muted-foreground">已启用</CardTitle
><CheckCircle2 class="size-4 text-muted-foreground"
/></CardHeader>
<CardContent class="px-4"
><div class="text-xl font-semibold tabular-nums">
{{ enabledCount.toLocaleString() }}
</div></CardContent
>
</Card>
<Card class="gap-2 py-3">
<CardHeader class="flex items-center justify-between px-4 pb-0 pt-0"
><CardTitle class="text-xs font-medium text-muted-foreground">已选择</CardTitle
><Badge variant="secondary">{{ selectedCount }}</Badge></CardHeader
>
<CardContent class="px-4"
><div class="text-xl font-semibold tabular-nums">{{ selectedCount }}</div></CardContent
>
</Card>
</section>
<section class="flex min-h-0 flex-1 flex-col gap-3 rounded-lg border bg-card p-3 sm:p-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 class="text-base font-semibold">记录清单</h2>
<p class="mt-1 text-xs text-muted-foreground">
点击文本或下拉单元格开始编辑,复选框可直接切换。
</p>
</div>
<div class="flex flex-wrap items-end gap-2">
<Button size="sm" @click="addRow">
<Plus data-icon="inline-start" />
新增行
</Button>
<label class="grid gap-1 text-xs text-muted-foreground">
<span>搜索</span>
<Input
v-model="searchQuery"
class="h-8 w-44 text-xs"
placeholder="编号、名称或负责人"
/>
</label>
<label class="grid gap-1 text-xs text-muted-foreground">
<span>状态</span>
<Select v-model="statusFilter">
<SelectTrigger size="sm" class="h-8 w-28"
><SelectValue placeholder="全部状态"
/></SelectTrigger>
<SelectContent
><SelectGroup
><SelectItem value="all">全部状态</SelectItem
><SelectItem value="1">启用</SelectItem
><SelectItem value="0">禁用</SelectItem></SelectGroup
></SelectContent
>
</Select>
</label>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs tabular-nums text-muted-foreground">
{{ filteredRows.length.toLocaleString() }} / {{ dataSource.length.toLocaleString() }} 条
</span>
<div class="relative">
<Search
class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
v-model="searchQuery"
class="h-8 w-56 pl-8 text-xs"
aria-label="搜索编号、名称或负责人"
/>
</div>
<Select v-model="statusFilter">
<SelectTrigger size="sm" class="h-8 w-28" aria-label="筛选状态">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="1">启用</SelectItem>
<SelectItem value="0">禁用</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
</div>
</div>
<div class="min-h-[460px] flex-1">
<FmsTable
ref="tableRef"
v-model:columns="columns"
:data-source="filteredRows"
:row-selection="rowSelection"
:active-row-key="activeRowKey"
row-key="id"
row-draggable
show-overflow
@change="onTableChange"
@row-order-change="onRowOrderChange"
/>
</div>
</section>
<section class="grid gap-3 lg:grid-cols-2">
<Card class="gap-4 py-4">
<CardHeader class="px-4 pb-0 pt-0"
><CardTitle class="text-sm">FmsSelect 远程选择</CardTitle></CardHeader
>
<CardContent class="px-4">
<div class="grid gap-3 sm:grid-cols-[240px_1fr] sm:items-center">
<FmsSelect
v-model="localeCode"
v-model:label="localeLabel"
view-name="b_i18n_type"
label-key="b_name"
value-key="b_code"
order-by="b_code ASC"
:page-size="10"
allow-clear
placeholder="选择语言类型..."
@change="localeRecord = $event"
/>
<div class="grid gap-1 text-xs text-muted-foreground">
<span
>值:<code class="text-foreground">{{ localeCode || "-" }}</code></span
><span
>记录:<code class="text-foreground">{{
localeRecord ? JSON.stringify(localeRecord) : "-"
}}</code></span
>
</div>
</div>
</CardContent>
</Card>
<Card class="gap-4 py-4">
<CardHeader class="px-4 pb-0 pt-0"
><CardTitle class="text-sm">FmsAutoComplete 搜索</CardTitle></CardHeader
>
<CardContent class="px-4">
<div class="grid gap-3 sm:grid-cols-[240px_1fr] sm:items-center">
<FmsAutoComplete
v-model="autoValue"
view-name="b_i18n_type"
label-key="b_name"
value-key="b_code"
order-by="b_code ASC"
placeholder="输入语言关键字..."
@change="autoRecord = $event"
/>
<div class="grid gap-1 text-xs text-muted-foreground">
<span
>输入:<code class="text-foreground">{{ autoValue || "-" }}</code></span
><span
>记录:<code class="text-foreground">{{
autoRecord ? JSON.stringify(autoRecord) : "-"
}}</code></span
>
</div>
</div>
</CardContent>
</Card>
</section>
<div class="min-h-0 flex-1">
<FmsTable
ref="tableRef"
v-model:columns="columns"
:data-source="filteredRows"
:row-selection="rowSelection"
:active-row-key="activeRowKey"
row-key="id"
row-draggable
scroll-row-by-row="scrollbar"
show-overflow
@change="onTableChange"
@row-order-change="onRowOrderChange"
/>
</div>
</main>
</template>
@@ -0,0 +1,48 @@
import { createTempId } from "@/utils/tempId";
const BUSINESS_CODE_FIELDS = [
"b_ywno",
"b_htno",
"b_code",
"b_account",
"b_bill_no",
"b_invoice_no",
"b_business_id",
];
function defaultFieldId(fields: Record<string, unknown>[]): string | null {
const textFields = (fields || []).filter(
(item) =>
Number(item.b_canuse) === 1 &&
item.b_field !== "b_id" &&
(item.b_type === "input" || item.b_type === "textarea"),
);
let field = null;
for (const fieldName of BUSINESS_CODE_FIELDS) {
field = textFields.find((item) => item.b_field === fieldName);
if (field) break;
}
if (!field) field = textFields[0];
return field?.b_id != null ? String(field.b_id) : null;
}
export function createAutoCodeDraft(
moduleId: string,
fields: Record<string, unknown>[],
enabled = false,
) {
return {
b_id: createTempId(),
b_module_id: String(moduleId),
b_field_id: defaultFieldId(fields),
b_prefix: "",
b_date_format: "yyyyMM",
b_separator: "-",
b_sequence_width: 5,
b_reset_type: "month",
b_start_value: 1,
b_current_period: null,
b_current_value: 0,
b_canuse: enabled ? 1 : 0,
};
}
@@ -0,0 +1,325 @@
<script setup lang="ts">
import { ref, watch, nextTick } from "vue";
import { Plus, Trash2, FolderTree } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import FmsTree from "@/components/FmsTree.vue";
import {
loadDataApi,
saveObjectApi,
} from "@/services/api";
import {
diffRows,
tableChange,
hasChanges,
allocateTemporaryIds,
cloneData,
} from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
import { buildTree } from "@/utils/tree";
import { toast } from "vue-sonner";
import { useConfirm } from "@/composables/useConfirm";
import type { TreeNode, ContextMenuItemDef } from "@/components/FmsTreeNode.vue";
const { confirm } = useConfirm();
const props = defineProps<{
open: boolean;
moduleId: string;
groups: Record<string, unknown>[];
referencedGroupIds?: string[];
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
(e: "saved", groups: Record<string, unknown>[], mapping: Map<string, string>): void;
}>();
const groupTree = ref<Record<string, unknown>[]>([]);
const selectedGroupId = ref<string | null>(null);
const expandedKeys = ref<string[]>([]);
const saving = ref(false);
const dirty = ref(false);
function buildGroupTree(items: Record<string, unknown>[]) {
const cloned = items.map((g) => ({
...g,
key: String(g.b_id),
title: String(g.b_name || ""),
b_parent_id: g.b_parent_id != null ? String(g.b_parent_id) : null,
}));
return buildTree(cloned as never);
}
function rebuildTree() {
groupTree.value = buildGroupTree(props.groups);
}
watch(() => props.open, (v) => {
if (v) {
rebuildTree();
const all = props.groups.map((g) => String(g.b_id));
expandedKeys.value = all;
dirty.value = false;
selectedGroupId.value = null;
}
});
const selectedGroup = ref<Record<string, unknown> | null>(null);
watch(selectedGroupId, (id) => {
if (!id) {
selectedGroup.value = null;
return;
}
selectedGroup.value =
props.groups.find((g) => String(g.b_id) === id) || null;
});
function findNode(key: string): Record<string, unknown> | null {
const walk = (nodes: Record<string, unknown>[]): Record<string, unknown> | null => {
for (const n of nodes) {
if (String(n.b_id) === key) return n;
const children = n.children as Record<string, unknown>[] | undefined;
if (children) {
const found = walk(children);
if (found) return found;
}
}
return null;
};
return walk(groupTree.value);
}
function hasChildren(key: string): boolean {
const node = findNode(key);
return !!(node && (node.children as unknown[])?.length);
}
function isReferenced(key: string): boolean {
return (props.referencedGroupIds || []).includes(key);
}
function onTreeSelect(_keys: string[]) {
const id = _keys[0];
selectedGroupId.value = id || null;
}
function buildContextMenu(node: TreeNode): ContextMenuItemDef[] {
const items: ContextMenuItemDef[] = [];
items.push({
key: "add-child",
label: "添加子分组",
});
items.push({
key: "delete",
label: "删除",
});
return items;
}
function onMenuClick({ key, node }: { key: string; node: TreeNode }) {
if (key === "add-child") {
const parentId = node.isFmsRoot ? null : String(node.key);
addGroup(parentId);
} else if (key === "delete") {
deleteGroup(String(node.key));
}
}
function addGroup(parentId: string | null) {
const newGroup: Record<string, unknown> = {
b_id: createTempId(),
b_module_id: props.moduleId,
b_name: "",
b_i18n: null,
b_xh: (props.groups.length + 1) * 10,
b_canuse: 1,
b_parent_id: parentId,
};
props.groups.push(newGroup);
dirty.value = true;
rebuildTree();
selectedGroupId.value = String(newGroup.b_id);
if (parentId) {
const parentKey = String(parentId);
if (!expandedKeys.value.includes(parentKey)) {
expandedKeys.value = [...expandedKeys.value, parentKey];
}
}
}
async function deleteGroup(groupId: string) {
if (hasChildren(groupId)) {
toast.error("该分组下还有子分组,请先删除子分组。");
return;
}
if (isReferenced(groupId)) {
toast.error("该分组已被列表配置或编辑配置引用,请先取消引用。");
return;
}
const ok = await confirm({ title: "删除分组", description: "确定删除该分组?", confirmText: "删除", danger: true });
if (!ok) return;
const walk = (nodes: Record<string, unknown>[]): boolean => {
const idx = nodes.findIndex((n) => String(n.b_id) === groupId);
if (idx !== -1) {
nodes.splice(idx, 1);
return true;
}
for (const n of nodes) {
const children = n.children as Record<string, unknown>[] | undefined;
if (children && walk(children)) return true;
}
return false;
};
walk(groupTree.value);
dirty.value = true;
selectedGroupId.value = null;
}
async function saveGroups() {
saving.value = true;
try {
const current = props.groups.map((g) => ({
...g,
b_module_id: props.moduleId,
}));
// Load originals from server for diff
const res = await loadDataApi(
"s_module_field_group",
`b_module_id = '${props.moduleId}'`,
"b_xh ASC, b_id ASC",
);
const prev = (res.data || []) as Record<string, unknown>[];
const changes = diffRows(current as never, prev as never);
const req = tableChange("s_module_field_group", changes);
if (hasChanges(req)) {
const mapping = await allocateTemporaryIds([
req.inserts || [],
req.updates || [],
req.deletes || [],
]);
await saveObjectApi([req]);
dirty.value = false;
emit("saved", cloneData(current), mapping);
}
} finally {
saving.value = false;
}
}
async function close() {
if (dirty.value) {
const ok = await confirm({ title: "关闭", description: "分组有未保存的变更,确定关闭吗?" });
if (!ok) return;
}
emit("update:open", false);
}
</script>
<template>
<Teleport to="body">
<div
v-if="open"
class="fixed inset-0 z-50 flex items-center justify-center"
>
<!-- Backdrop -->
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm" @click="close" />
<!-- Dialog -->
<div class="relative z-50 w-[720px] max-h-[80vh] flex flex-col rounded-lg border bg-card shadow-xl">
<!-- Header -->
<div class="flex items-center justify-between border-b px-5 py-3">
<div class="flex items-center gap-2">
<FolderTree :size="16" class="text-muted-foreground" />
<span class="text-[13px] font-semibold">分组管理</span>
</div>
<button
type="button"
class="rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
@click="close"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<!-- Body -->
<div class="flex flex-1 min-h-0 overflow-hidden">
<!-- Left: Tree -->
<div class="w-60 shrink-0 border-r overflow-auto p-2">
<FmsTree
:tree-data="groupTree as never"
v-model:expanded-keys="expandedKeys"
:selected-keys="selectedGroupId ? [selectedGroupId] : []"
:context-menu="buildContextMenu"
root-title="分组"
@select="onTreeSelect"
@menu-click="onMenuClick"
/>
</div>
<!-- Right: Detail -->
<div class="flex-1 p-4 overflow-auto">
<template v-if="selectedGroup">
<div class="space-y-4">
<div class="space-y-2">
<Label class="text-[13px]">分组名称</Label>
<Input
v-model="(selectedGroup.b_name as string)"
class="h-8 text-[13px]"
@input="dirty = true"
/>
</div>
<div class="space-y-2">
<Label class="text-[13px]">多语言键</Label>
<Input
v-model="(selectedGroup.b_i18n as string)"
class="h-8 text-[13px]"
@input="dirty = true"
/>
</div>
<div class="space-y-2">
<Label class="text-[13px]">排序号</Label>
<Input
:model-value="String(selectedGroup.b_xh ?? 0)"
type="number"
class="h-8 text-[13px]"
@update:model-value="(v) => { (selectedGroup.b_xh as number) = Number(v); dirty = true }"
/>
</div>
<div class="flex items-center gap-2">
<Switch
:checked="Number(selectedGroup.b_canuse) === 1"
@update:checked="(v) => { (selectedGroup.b_canuse as number) = v ? 1 : 0; dirty = true }"
/>
<Label class="text-[13px]">启用</Label>
</div>
</div>
</template>
<div v-else class="flex items-center justify-center h-full text-sm text-muted-foreground">
请从左侧选择分组
</div>
</div>
</div>
<!-- Footer -->
<div class="flex items-center justify-between border-t px-5 py-2.5">
<Button variant="outline" size="sm" class="h-7 text-[12px]" @click="addGroup(null)">
<Plus :size="13" class="mr-1" />
添加分组
</Button>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="h-7 text-[12px]" @click="close">取消</Button>
<Button size="sm" class="h-7 text-[12px]" :disabled="saving" @click="saveGroups">
{{ saving ? "保存中…" : "保存" }}
</Button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { loadDataApi, saveObjectApi } from "@/services/api";
const props = defineProps<{
open: boolean;
i18nKey: string;
defaultValue: string;
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
(e: "saved", key: string): void;
}>();
const translations = ref<Record<string, string>>({});
const locales = ref<string[]>([]);
const loading = ref(false);
const saving = ref(false);
watch(() => props.open, async (v) => {
if (!v) return;
loading.value = true;
try {
const key = props.i18nKey;
if (!key) {
translations.value = {};
return;
}
// Load language types
const langRes = await loadDataApi(
"b_i18n_type",
"b_canuse = 1",
"b_default DESC, b_xh ASC, b_id ASC",
);
const langs = (langRes.data || []) as { b_code: string; b_default: number }[];
locales.value = langs.map((l) => l.b_code);
// Load existing translations
const i18nRes = await loadDataApi("b_i18n", `b_key = '${key}'`);
const existing = (i18nRes.data || []) as { b_locale: string; b_value: string }[];
const values: Record<string, string> = {};
for (const lang of locales.value) {
const found = existing.find((e) => e.b_locale === lang);
values[lang] = found?.b_value ?? "";
}
// Set default value for primary locale
const primary = langs.find((l) => Number(l.b_default) === 1);
if (primary && !values[primary.b_code]) {
values[primary.b_code] = props.defaultValue;
}
translations.value = values;
} finally {
loading.value = false;
}
});
async function save() {
saving.value = true;
try {
const key = props.i18nKey;
if (!key) return;
const inserts: Record<string, unknown>[] = [];
for (const [locale, value] of Object.entries(translations.value)) {
if (!value) continue;
inserts.push({
b_key: key,
b_locale: locale,
b_value: value,
});
}
if (inserts.length) {
await saveObjectApi([
{ table: "b_i18n", key_field: "b_key", inserts },
]);
}
emit("saved", key);
emit("update:open", false);
} finally {
saving.value = false;
}
}
function close() {
emit("update:open", false);
}
</script>
<template>
<Teleport to="body">
<div v-if="open" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="fixed inset-0 bg-black/50 backdrop-blur-sm" @click="close" />
<div class="relative z-50 w-[480px] max-h-[70vh] flex flex-col rounded-lg border bg-card shadow-xl">
<div class="flex items-center justify-between border-b px-5 py-3">
<span class="text-[13px] font-semibold">多语言快速设置</span>
<button
type="button"
class="rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground"
@click="close"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="flex-1 overflow-auto p-5 space-y-3">
<div class="space-y-1.5">
<Label class="text-[12px]">翻译键</Label>
<Input :model-value="i18nKey" class="h-8 text-[13px]" disabled />
</div>
<div v-if="loading" class="text-center text-sm text-muted-foreground py-6">
加载中…
</div>
<div v-else v-for="locale in locales" :key="locale" class="space-y-1.5">
<Label class="text-[12px]">{{ locale }}</Label>
<Input
v-model="translations[locale]"
class="h-8 text-[13px]"
/>
</div>
</div>
<div class="flex items-center justify-end gap-2 border-t px-5 py-2.5">
<Button variant="outline" size="sm" class="h-7 text-[12px]" @click="close">取消</Button>
<Button size="sm" class="h-7 text-[12px]" :disabled="saving" @click="save">
{{ saving ? "保存中…" : "保存" }}
</Button>
</div>
</div>
</div>
</Teleport>
</template>
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from "vue";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { createAutoCodeDraft } from "../common";
const props = defineProps<{ moduleId: string; autoCode: Record<string, unknown>[]; fields: Record<string, unknown>[]; loading?: boolean }>();
const emit = defineEmits<{ (e: "update", rows: Record<string, unknown>[]): void }>();
const rule = computed(() => props.autoCode?.length ? props.autoCode[0] : null);
const enabled = computed(() => Number(rule.value?.b_canuse) === 1);
const FMT = ["yyyy","yy","yyyyMM","yyyy-MM","yyyyMMdd","yyyy-MM-dd","yyyy/MM/dd","yyyyMMddHHmm","yyyy-MM-dd HH:mm","yyyy/MM/dd HH:mm"];
const AVF = computed(() => (props.fields || []).filter((f) => Number(f.b_canuse) === 1 && f.b_field !== "b_id" && (f.b_type === "input" || f.b_type === "textarea")));
const RST = [{ value: "none", label: "不重置" },{ value: "year", label: "按年" },{ value: "month", label: "按月" },{ value: "day", label: "按日" }];
const preview = computed(() => { if (!rule.value || !enabled.value) return ""; const p = String(rule.value.b_prefix || ""), sep = String(rule.value.b_separator || ""), fmt = String(rule.value.b_date_format || ""), w = Number(rule.value.b_sequence_width || 5), sv = Number(rule.value.b_start_value || 1); const d = new Date(), Y = String(d.getFullYear()), y = Y.slice(2), M = String(d.getMonth()+1).padStart(2,"0"), D = String(d.getDate()).padStart(2,"0"); const ds = fmt.replace("yyyy",Y).replace("yy",y).replace("MM",M).replace("dd",D); return `${p}${ds}${sep}${String(sv).padStart(w,"0")}`; });
function setEn(v: boolean) { if (v && !rule.value) { emit("update", [createAutoCodeDraft(props.moduleId, props.fields as any, true)]); } else if (rule.value) { emit("update", [{ ...rule.value, b_canuse: v ? 1 : 0 }]); } }
function up(key: string, value: unknown) { if (!rule.value) return; emit("update", [{ ...rule.value, [key]: value }]); }
</script>
<script lang="ts">export default { name: "ModuleAutoCodePanel" };</script>
<template>
<div class="h-full min-h-0 overflow-y-auto space-y-5">
<div class="flex items-center justify-between rounded-lg border p-3">
<div>
<div class="text-[13px] font-semibold">自动编码</div>
<div class="text-[12px] text-muted-foreground mt-0.5">开启后将按照配置规则自动生成唯一编号</div>
</div>
<Switch :checked="enabled" :disabled="loading" @update:checked="setEn" />
</div>
<template v-if="rule">
<div class="grid w-[640px] max-w-full grid-cols-2 gap-x-6 gap-y-4" :class="enabled ? '' : 'pointer-events-none opacity-50'">
<div class="space-y-1.5"><Label class="text-[12px]">业务编号字段</Label><Select :model-value="String(rule.b_field_id ?? '')" :disabled="!enabled || loading" @update:model-value="(v: string) => up('b_field_id', v)"><SelectTrigger class="h-8 text-[13px]"><SelectValue placeholder="请选择" /></SelectTrigger><SelectContent><SelectItem v-for="f in AVF" :key="String(f.b_id)" :value="String(f.b_id)">{{ String(f.b_field) }}</SelectItem></SelectContent></Select></div>
<div class="space-y-1.5"><Label class="text-[12px]">固定前缀</Label><Input :model-value="String(rule.b_prefix ?? '')" class="h-8 text-[13px]" placeholder="例如: ORD" :disabled="!enabled || loading" @update:model-value="(v: string) => up('b_prefix', v)" /></div>
<div class="space-y-1.5"><Label class="text-[12px]">日期格式</Label><Select :model-value="String(rule.b_date_format ?? 'yyyyMM')" :disabled="!enabled || loading" @update:model-value="(v: string) => up('b_date_format', v)"><SelectTrigger class="h-8 text-[13px]"><SelectValue /></SelectTrigger><SelectContent><SelectItem v-for="f in FMT" :key="f" :value="f">{{ f }}</SelectItem></SelectContent></Select></div>
<div class="space-y-1.5"><Label class="text-[12px]">分隔符</Label><Input :model-value="String(rule.b_separator ?? '-')" class="h-8 text-[13px]" placeholder="-" :disabled="!enabled || loading" @update:model-value="(v: string) => up('b_separator', v)" /></div>
<div class="space-y-1.5"><Label class="text-[12px]">流水号位数 (1-18)</Label><Input :model-value="String(rule.b_sequence_width ?? 5)" type="number" class="h-8 text-[13px]" :disabled="!enabled || loading" @update:model-value="(v: string) => up('b_sequence_width', Number(v))" /></div>
<div class="space-y-1.5"><Label class="text-[12px]">起始值</Label><Input :model-value="String(rule.b_start_value ?? 1)" type="number" class="h-8 text-[13px]" :disabled="!enabled || loading" @update:model-value="(v: string) => up('b_start_value', Number(v))" /></div>
<div class="space-y-1.5"><Label class="text-[12px]">重置周期</Label><Select :model-value="String(rule.b_reset_type ?? 'month')" :disabled="!enabled || loading" @update:model-value="(v: string) => up('b_reset_type', v)"><SelectTrigger class="h-8 text-[13px]"><SelectValue /></SelectTrigger><SelectContent><SelectItem v-for="r in RST" :key="r.value" :value="r.value">{{ r.label }}</SelectItem></SelectContent></Select></div>
</div>
<div v-if="enabled" class="rounded-lg border bg-muted/20 p-3"><span class="text-[12px] text-muted-foreground mr-2">预览</span><code class="text-[14px] font-medium">{{ preview }}</code></div>
</template>
</div>
</template>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { Globe } from "@lucide/vue";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
import { MODULE_TYPE_LABELS } from "@/types/module";
import { buildModuleI18nKey } from "@/utils/i18n";
const props = defineProps<{ moduleId: string; data: Record<string, unknown> | null; parentName?: string; loading?: boolean }>();
const emit = defineEmits<{ (e: "update", data: Record<string, unknown>): void }>();
const moduleTypeOptions = Object.entries(MODULE_TYPE_LABELS).map(([v, l]) => ({ value: v, label: l }));
const iconList = ["Folder","FileText","Database","Settings","Users","Globe","Home","Layout","Package","Shield","BarChart","PieChart","List","Grid","Calendar","Clock","Map","Mail","MessageSquare","Search","Code","Terminal","Book","Zap","Star","Heart","Award","Trophy","Briefcase","ShoppingCart"];
const i18nModalOpen = ref(false);
function openI18nModal() { i18nModalOpen.value = true; }
function updateField(key: string, value: unknown) {
if (!props.data) return;
const u: Record<string, unknown> = { ...props.data, [key]: value };
if (key === "b_code") u.b_i18n = buildModuleI18nKey(String(u.b_module_type), String(value || ""));
emit("update", u);
}
</script>
<script lang="ts">import { ref } from "vue"; export default { name: "ModuleBasicPanel" };</script>
<template>
<div class="h-full min-h-0 overflow-y-auto space-y-5">
<h3 class="text-[13px] font-semibold">基本信息</h3>
<div class="grid w-[640px] max-w-full grid-cols-2 gap-x-6 gap-y-4">
<!-- 编码 -->
<div class="space-y-1.5"><Label class="text-[12px]">编码 <span class="text-destructive">*</span></Label><Input :model-value="String(data?.b_code ?? '')" class="h-8 text-[13px]" :disabled="loading" @update:model-value="(v: string) => updateField('b_code', v)" /></div>
<!-- 名称 -->
<div class="space-y-1.5"><Label class="text-[12px]">名称 <span class="text-destructive">*</span></Label><div class="flex items-center gap-1"><Input :model-value="String(data?.b_name ?? '')" class="h-8 flex-1 text-[13px]" :disabled="loading" @update:model-value="(v: string) => updateField('b_name', v)" /><Button variant="ghost" size="icon-xs" :disabled="loading" @click="openI18nModal"><Globe :size="14" /></Button></div></div>
<!-- 类型 -->
<div class="space-y-1.5"><Label class="text-[12px]">模块类型</Label><Select :model-value="String(data?.b_module_type ?? '')" :disabled="loading" @update:model-value="(v: string) => updateField('b_module_type', v)"><SelectTrigger class="h-8 text-[13px]"><SelectValue /></SelectTrigger><SelectContent><SelectItem v-for="o in moduleTypeOptions" :key="o.value" :value="o.value">{{ o.label }}</SelectItem></SelectContent></Select></div>
<!-- 上级 -->
<div class="space-y-1.5"><Label class="text-[12px]">上级模块</Label><Input :model-value="parentName ?? ''" class="h-8 text-[13px]" disabled /></div>
<!-- 排序 -->
<div class="space-y-1.5"><Label class="text-[12px]">排序号</Label><Input :model-value="String(data?.b_xh ?? 0)" type="number" class="h-8 text-[13px]" :disabled="loading" @update:model-value="(v: string) => updateField('b_xh', Number(v))" /></div>
<!-- 图标 -->
<div class="space-y-1.5"><Label class="text-[12px]">图标</Label><Select :model-value="String(data?.b_icon ?? '') || 'none'" :disabled="loading" @update:model-value="(v: string) => updateField('b_icon', v === 'none' ? '' : v)"><SelectTrigger class="h-8 text-[13px]"><SelectValue /></SelectTrigger><SelectContent><SelectItem value="none">(无)</SelectItem><SelectItem v-for="i in iconList" :key="i" :value="i">{{ i }}</SelectItem></SelectContent></Select></div>
<!-- 路由 -->
<div v-if="data?.b_module_type === 'page'" class="space-y-1.5"><Label class="text-[12px]">页面路由 <span class="text-destructive">*</span></Label><Input :model-value="String(data?.b_route ?? '')" class="h-8 text-[13px]" :disabled="loading" @update:model-value="(v: string) => updateField('b_route', v)" /></div>
<!-- 视图 -->
<div v-if="data?.b_module_type === 'data'" class="space-y-1.5"><Label class="text-[12px]">查询视图 <span class="text-destructive">*</span></Label><Input :model-value="String(data?.b_viewtable ?? '')" class="h-8 text-[13px]" :disabled="loading" @update:model-value="(v: string) => updateField('b_viewtable', v)" /></div>
<!-- 保存表 -->
<div v-if="data?.b_module_type === 'data'" class="space-y-1.5"><Label class="text-[12px]">保存表</Label><Input :model-value="String(data?.b_savetable ?? '')" class="h-8 text-[13px]" :disabled="loading" @update:model-value="(v: string) => updateField('b_savetable', v)" /></div>
<!-- 主键 -->
<div v-if="data?.b_module_type === 'data'" class="space-y-1.5"><Label class="text-[12px]">主键字段</Label><Input :model-value="String(data?.b_keyfield ?? '')" class="h-8 text-[13px]" :disabled="loading" @update:model-value="(v: string) => updateField('b_keyfield', v)" /></div>
<!-- 排序字段 -->
<div v-if="data?.b_module_type === 'data'" class="space-y-1.5"><Label class="text-[12px]">默认排序字段</Label><Input :model-value="String(data?.b_orderfield ?? '')" class="h-8 text-[13px]" :disabled="loading" @update:model-value="(v: string) => updateField('b_orderfield', v)" /></div>
<!-- 多语言键 -->
<div class="space-y-1.5"><Label class="text-[12px]">多语言键</Label><Input :model-value="String(data?.b_i18n ?? '')" class="h-8 text-[13px]" disabled /></div>
</div>
<I18nQuickSetModal v-if="data" :open="i18nModalOpen" :i18n-key="String(data.b_i18n ?? '')" :default-value="String(data.b_name || data.b_code || '')" @update:open="(v: boolean) => i18nModalOpen = v" />
</div>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ref } from "vue";
import { ChevronDown } from "@lucide/vue";
const props = withDefaults(
defineProps<{
title?: string;
collapsible?: boolean;
defaultOpen?: boolean;
}>(),
{
title: "卡片",
collapsible: true,
defaultOpen: true,
},
);
const collapsed = ref(!props.defaultOpen);
function toggle() {
if (!props.collapsible) return;
collapsed.value = !collapsed.value;
}
</script>
<template>
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<div
class="flex items-center justify-between border-b px-4 py-2.5"
:class="collapsible ? 'cursor-pointer select-none' : ''"
@click="toggle"
>
<span class="text-[13px] font-semibold">{{ title }}</span>
<div class="flex items-center gap-2">
<slot name="header-actions" />
<button
v-if="collapsible"
type="button"
class="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-muted"
>
<ChevronDown
:size="14"
class="transition-transform duration-200"
:class="collapsed ? '-rotate-90' : ''"
/>
</button>
</div>
</div>
<div
v-show="!collapsed"
class="p-4"
>
<slot />
</div>
</div>
</template>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { h, ref } from "vue";
import { FolderTree, ArrowRightLeft } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import FieldGroupManager from "./FieldGroupManager.vue";
const props = defineProps<{ moduleId: string; editConfig: Record<string, unknown>[]; listConfig: Record<string, unknown>[]; queryConfig: Record<string, unknown>[]; fields: Record<string, unknown>[]; groups: Record<string, unknown>[]; referencedGroupIds?: string[]; loading?: boolean }>();
const emit = defineEmits<{ (e: "update", rows: Record<string, unknown>[]): void; (e: "groups-saved", groups: Record<string, unknown>[], mapping: Map<string, string>): void; (e: "sync-list", rows: Record<string, unknown>[]): void; (e: "sync-query", rows: Record<string, unknown>[]): void }>();
const gmOpen = ref(false);
function fname(id: unknown) { const f = props.fields.find((x) => String(x.b_id) === String(id)); return f ? String(f.b_field || "") : String(id || ""); }
function gname(id: unknown) { const g = props.groups.find((x) => String(x.b_id) === String(id)); return g ? String(g.b_name || g.b_i18n || "") : ""; }
function up(idx: number, key: string, value: unknown) { const u = [...props.editConfig]; u[idx] = { ...u[idx], [key]: value }; emit("update", u); }
function onCellChange(row: Record<string, unknown>, dataIndex: string, value: unknown) { const idx = props.editConfig.findIndex((x) => String(x.b_id) === String(row.b_id)); if (idx >= 0) up(idx, dataIndex, value); }
function syncList() {
const e = props.editConfig.filter((r) => Number(r.b_visible) === 1);
const lid = new Set(props.listConfig.map((r) => String(r.b_field_id)));
const nr = e.filter((r) => !lid.has(String(r.b_field_id))).map((r) => ({ b_id: (r as any).b_id, b_module_id: props.moduleId, b_field_id: r.b_field_id, b_width: 100, b_group_id: r.b_group_id || null, b_visible: 1, b_xh: r.b_xh || 0 }));
if (nr.length) emit("sync-list", [...props.listConfig, ...nr]);
}
function syncQuery() {
const e = props.editConfig.filter((r) => Number(r.b_visible) === 1);
const qid = new Set(props.queryConfig.map((r) => String(r.b_field_id)));
const nr = e.filter((r) => !qid.has(String(r.b_field_id))).map((r) => ({ b_id: (r as any).b_id, b_module_id: props.moduleId, b_field_id: r.b_field_id, b_component: "input", b_operator: "=", b_default_value: null, b_xh: r.b_xh || 0, b_canuse: 1 }));
if (nr.length) emit("sync-query", [...props.queryConfig, ...nr]);
}
function onRowOrderChange(rows: Record<string, unknown>[]) { const reordered = rows.map((x, i) => ({ ...x, b_xh: (i + 1) * 10 })); emit("update", reordered); }
function fieldCell(row: Record<string, unknown>) { return h("span", { class: "text-[13px]" }, fname(row.b_field_id)); }
function groupCell(row: Record<string, unknown>) { return h("span", { class: "text-[13px] text-muted-foreground" }, gname(row.b_group_id)); }
const columns = ref([
{ dataIndex: "b_field_id", title: "字段", width: 130, customCell: ({ row }: { row: Record<string, unknown> }) => fieldCell(row) },
{ dataIndex: "b_visible", title: "显示", width: 60, editor: { type: "checkbox", onChange: (v: number, row: Record<string, unknown>) => onCellChange(row, "b_visible", v) } },
{ dataIndex: "b_required", title: "必填", width: 60, editor: { type: "checkbox", onChange: (v: number, row: Record<string, unknown>) => onCellChange(row, "b_required", v) } },
{ dataIndex: "b_readonly", title: "只读", width: 60, editor: { type: "checkbox", onChange: (v: number, row: Record<string, unknown>) => onCellChange(row, "b_readonly", v) } },
{ dataIndex: "b_disabled", title: "禁用", width: 60, editor: { type: "checkbox", onChange: (v: number, row: Record<string, unknown>) => onCellChange(row, "b_disabled", v) } },
{ dataIndex: "b_group_id", title: "分组", width: 100, customCell: ({ row }: { row: Record<string, unknown> }) => groupCell(row) },
{ dataIndex: "b_xh", title: "排序", width: 70, editor: { type: "input", inputType: "number", onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_xh", Number(v)) } },
]);
</script>
<script lang="ts">export default { name: "ModuleEditConfigPanel" };</script>
<template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<h3 class="text-[13px] font-semibold">编辑配置</h3>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="h-7 text-[12px]" :disabled="loading" @click="gmOpen = true"><FolderTree :size="12" class="mr-1" />分组</Button>
<Button variant="outline" size="sm" class="h-7 text-[12px]" :disabled="loading" @click="syncList"><ArrowRightLeft :size="12" class="mr-1" />同步列表</Button>
<Button variant="outline" size="sm" class="h-7 text-[12px]" :disabled="loading" @click="syncQuery"><ArrowRightLeft :size="12" class="mr-1" />同步查询</Button>
</div>
</div>
<FmsTable
class="min-h-0 flex-1"
v-model:columns="columns"
:data-source="editConfig"
row-key="b_id"
:editable="!loading"
:virtual="false"
:row-draggable="!loading"
@row-order-change="onRowOrderChange"
/>
<FieldGroupManager :open="gmOpen" :module-id="moduleId" :groups="groups" :referenced-group-ids="referencedGroupIds" @update:open="(v: boolean) => gmOpen = v" @saved="(g, m) => emit('groups-saved', g, m)" />
</div>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { h, ref } from "vue";
import { Globe } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
import { buildModuleItemI18nKey } from "@/utils/i18n";
const props = defineProps<{ moduleId: string; moduleCode: string; fields: Record<string, unknown>[]; loading?: boolean }>();
const emit = defineEmits<{ (e: "update", fields: Record<string, unknown>[]): void; (e: "sync-fields"): void }>();
const i18nModalOpen = ref(false); const i18nTargetKey = ref(""); const i18nDefaultName = ref("");
function openI18nModal(f: Record<string, unknown>) { i18nTargetKey.value = String(f.b_i18n || ""); i18nDefaultName.value = String(f.b_name || f.b_field || ""); i18nModalOpen.value = true; }
function updateField(idx: number, key: string, value: unknown) {
const u = [...props.fields]; u[idx] = { ...u[idx], [key]: value };
if (key === "b_field") u[idx].b_i18n = buildModuleItemI18nKey("field", props.moduleCode, String(value));
emit("update", u);
}
function onCellChange(row: Record<string, unknown>, dataIndex: string, value: unknown) {
const idx = props.fields.findIndex((x) => String(x.b_id) === String(row.b_id));
if (idx >= 0) updateField(idx, dataIndex, value);
}
const CONTROL_TYPES = [{ value: "input", label: "文本" },{ value: "textarea", label: "多行文本" },{ value: "number", label: "数字" },{ value: "money", label: "金额" },{ value: "date", label: "日期" },{ value: "datetime", label: "日期时间" },{ value: "checkbox", label: "复选框" },{ value: "select", label: "下拉选择" }];
function i18nCell(row: Record<string, unknown>) {
return h("div", { class: "flex items-center gap-1" }, [
h("span", { class: "flex-1 truncate text-[12px] text-muted-foreground" }, String(row.b_i18n ?? "")),
h("button", {
type: "button",
class: "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground",
title: "设置多语言",
onClick: (e: MouseEvent) => { e.stopPropagation(); openI18nModal(row); },
}, [h(Globe, { size: 12 })]),
]);
}
const columns = ref([
{ dataIndex: "b_field", title: "字段名", width: 130 },
{ dataIndex: "b_name", title: "字段名称", width: 120, editor: { type: "input", onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_name", v) } },
{ dataIndex: "b_type", title: "控件类型", width: 110, editor: { type: "select", options: CONTROL_TYPES, allowClear: false, onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_type", v) } },
{ dataIndex: "b_default_value", title: "默认值", width: 110, editor: { type: "input", onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_default_value", v) } },
{ dataIndex: "b_i18n", title: "多语言键", width: 170, customCell: ({ row }: { row: Record<string, unknown> }) => i18nCell(row) },
{ dataIndex: "b_canuse", title: "启用", width: 60, editor: { type: "checkbox", onChange: (v: number, row: Record<string, unknown>) => onCellChange(row, "b_canuse", v) } },
]);
</script>
<script lang="ts">export default { name: "ModuleFieldsPanel" };</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex shrink-0 items-center justify-between">
<h3 class="text-[13px] font-semibold">字段定义</h3>
<Button variant="outline" size="sm" class="h-7 text-[12px]" :disabled="loading" @click="emit('sync-fields')">同步字段</Button>
</div>
<FmsTable
v-model:columns="columns"
:data-source="fields"
row-key="b_id"
:editable="!loading"
:virtual="false"
/>
<I18nQuickSetModal :open="i18nModalOpen" :i18n-key="i18nTargetKey" :default-value="i18nDefaultName" @update:open="(v: boolean) => i18nModalOpen = v" />
</div>
</template>
@@ -0,0 +1,271 @@
<script setup lang="ts">
import { ref, watch, computed, h } from "vue";
import { Plus, Trash2, Search } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { loadDataApi } from "@/services/api";
import { cloneData } from "@/utils/dataChanges";
import type { ManagedI18nItem, I18nRow } from "@/types/module";
const props = defineProps<{ moduleId: string; i18nItems: ManagedI18nItem[]; loading?: boolean }>();
const locales = ref<string[]>([]);
const dirty = ref(false);
const savedSnapshot = ref<I18nRow[]>([]);
const pivotRows = ref<I18nRow[]>([]);
const search = ref("");
const selectedRowKeys = ref<string[]>([]);
const filteredRows = computed(() => {
const kw = search.value.trim().toLowerCase();
if (!kw) return pivotRows.value;
return pivotRows.value.filter((r) => String(r.b_key).toLowerCase().includes(kw));
});
function isCtrl(row: I18nRow) {
return !!row._managedId;
}
function buildColumns(): any[] {
const keyCol = {
dataIndex: "b_key",
title: "翻译键",
width: 220,
editor: {
type: "input" as const,
disabled: (row: I18nRow) => !!row._managedId,
onChange: () => {
dirty.value = true;
},
},
};
const typeCol = {
dataIndex: "__type",
title: "类型",
width: 64,
customCell: ({ row }: { row: I18nRow }) =>
h(
"span",
{
class:
"inline-flex rounded px-1.5 py-0.5 text-[11px] " +
(isCtrl(row) ? "bg-blue-50 text-blue-700" : "bg-muted text-muted-foreground"),
},
isCtrl(row) ? "系统" : "自定义",
),
};
const localeCols = locales.value.map((loc) => ({
dataIndex: loc,
title: loc,
width: 140,
editor: {
type: "input" as const,
onChange: () => {
dirty.value = true;
},
},
}));
return [keyCol, typeCol, ...localeCols];
}
const columns = ref<any[]>(buildColumns());
const rowSelection = computed(() => ({
selectedRowKeys: selectedRowKeys.value,
getCheckboxProps: (row: I18nRow) => ({ disabled: !!row._managedId }),
onChange: (keys: (string | number)[]) => {
selectedRowKeys.value = keys.map(String);
},
}));
watch(locales, () => {
columns.value = buildColumns();
});
function reconcileManagedRows(items: ManagedI18nItem[]) {
const byKey = new Map(pivotRows.value.map((r) => [String(r.b_key), r]));
const from = new Map(items.map((i) => [i.key, i]));
const result: I18nRow[] = [];
for (const row of pivotRows.value) {
const key = String(row.b_key);
const item = from.get(key);
if (item) {
result.push({ ...row, _managedId: item.id || row._managedId });
from.delete(key);
} else result.push(row);
}
for (const [key, item] of from)
result.push({
_id: "i18n_" + Math.random().toString(36).slice(2, 10),
b_key: key,
_managedId: item.id,
_defaultValue: item.defaultValue,
_localeIds: {},
});
pivotRows.value = result;
}
async function buildSaveRequest() {
const saved = savedSnapshot.value,
cur = pivotRows.value;
if (!cur.length && !saved.length) return null;
const flat: Record<string, unknown>[] = [];
for (const row of cur) {
const key = String(row.b_key || "");
if (!key) continue;
for (const loc of locales.value) {
const val = (row as any)[loc];
if (val == null || val === "") continue;
flat.push({ b_key: key, b_locale: loc, b_value: val });
}
}
const keys = cur.map((r) => r.b_key).filter(Boolean);
let existing: Record<string, unknown>[] = [];
try {
if (keys.length) {
const jk = keys.map((k) => `'${k}'`).join(",");
const r = await loadDataApi("b_i18n", `b_key IN (${jk})`);
existing = (r.data || []) as Record<string, unknown>[];
}
} catch {
existing = [];
}
const exMap = new Map(existing.map((e) => [`${e.b_key}:${e.b_locale}`, e]));
const inserts: Record<string, unknown>[] = [],
updates: Record<string, unknown>[] = [],
deletes: Record<string, unknown>[] = [];
for (const fl of flat) {
const k = `${fl.b_key}:${fl.b_locale}`;
const ex = exMap.get(k);
if (!ex) inserts.push(fl);
else if (String(ex.b_value) !== String(fl.b_value)) updates.push({ ...ex, b_value: fl.b_value });
}
const curKL = new Set(flat.map((r) => `${r.b_key}:${r.b_locale}`));
for (const ex of existing) {
if (!curKL.has(`${ex.b_key}:${ex.b_locale}`)) deletes.push({ b_key: ex.b_key, b_locale: ex.b_locale });
}
if (!inserts.length && !updates.length && !deletes.length) return null;
const req: Record<string, unknown> = { table: "b_i18n", key_field: "b_key" };
if (inserts.length) req.inserts = inserts;
if (updates.length) req.updates = updates;
if (deletes.length) req.deletes = deletes;
return req;
}
function markSaved() {
savedSnapshot.value = cloneData(pivotRows.value);
dirty.value = false;
}
function mergeDefaults(entries: { key: string; locale: string; value: unknown; managedId?: string }[]) {
const byKey = new Map(pivotRows.value.map((r) => [String(r.b_key), r]));
const existM = new Set(pivotRows.value.map((r) => r._managedId).filter(Boolean).map(String));
for (const e of entries) {
if (!e.key) continue;
const ex = byKey.get(e.key);
if (ex) {
if ((ex as any)[e.locale] == null || (ex as any)[e.locale] === "") {
(ex as any)[e.locale] = e.value;
dirty.value = true;
}
continue;
}
if (e.managedId && existM.has(e.managedId)) continue;
const row: I18nRow = {
_id: "i18n_" + Math.random().toString(36).slice(2, 10),
b_key: e.key,
_managedId: e.managedId,
_syncInjected: true,
_localeIds: {},
_defaultValue: e.value as string,
[e.locale]: e.value,
};
pivotRows.value.push(row);
byKey.set(e.key, row);
if (e.managedId) existM.add(e.managedId);
dirty.value = true;
}
}
async function refreshKey(key: string) {
if (!key) return;
try {
const r = await loadDataApi("b_i18n", `b_key = '${key}'`);
const flat = (r.data || []) as { b_locale: string; b_value: string }[];
const row = pivotRows.value.find((x) => String(x.b_key) === key);
if (row) for (const f of flat) (row as any)[f.b_locale] = f.b_value;
} catch {
/* ignore */
}
}
function removeByManagedIds(managedIds: Set<string>) {
pivotRows.value = pivotRows.value.filter((r) => !r._managedId || !managedIds.has(r._managedId));
dirty.value = true;
}
defineExpose({ reconcileManagedRows, buildSaveRequest, markSaved, mergeDefaults, refreshKey, removeByManagedIds });
function addNewRow() {
pivotRows.value.push({ _id: "i18n_" + Math.random().toString(36).slice(2, 10), b_key: "", _localeIds: {} });
dirty.value = true;
}
function delSel() {
const sel = new Set(selectedRowKeys.value);
pivotRows.value = pivotRows.value.filter((r) => !r._id || !sel.has(String(r._id)));
selectedRowKeys.value = [];
dirty.value = true;
}
async function loadLocales() {
try {
const r = await loadDataApi("b_i18n_type", "b_canuse = 1", "b_default DESC, b_xh ASC, b_id ASC");
locales.value = ((r.data || []) as { b_code: string }[]).map((l) => l.b_code);
} catch {
locales.value = [];
}
}
watch(
() => props.i18nItems,
(items) => reconcileManagedRows(items),
{ immediate: true, deep: true },
);
loadLocales();
</script>
<script lang="ts">
export default { name: "ModuleI18nPanel" };
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex shrink-0 items-center justify-between">
<h3 class="text-[13px] font-semibold">多语言</h3>
<div class="flex items-center gap-2">
<Button
v-if="selectedRowKeys.length"
variant="destructive"
size="sm"
class="h-7 text-[12px]"
@click="delSel"
>
<Trash2 :size="12" class="mr-1" />批量删除
</Button>
<Button variant="outline" size="sm" class="h-7 text-[12px]" :disabled="loading" @click="addNewRow">
<Plus :size="12" class="mr-1" />新增翻译
</Button>
</div>
</div>
<div class="mb-3 shrink-0">
<div class="relative w-60">
<Search :size="14" class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" class="h-8 pl-8 text-[13px]" placeholder="搜索翻译键…" />
</div>
</div>
<FmsTable
class="min-h-0 flex-1"
v-model:columns="columns"
:data-source="filteredRows"
row-key="_id"
:row-selection="rowSelection"
:editable="!loading"
:virtual="false"
:seq="false"
/>
</div>
</template>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { h, ref } from "vue";
import { FolderTree, ArrowRightLeft } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import FieldGroupManager from "./FieldGroupManager.vue";
const props = defineProps<{ moduleId: string; listConfig: Record<string, unknown>[]; editConfig: Record<string, unknown>[]; queryConfig: Record<string, unknown>[]; fields: Record<string, unknown>[]; groups: Record<string, unknown>[]; referencedGroupIds?: string[]; loading?: boolean }>();
const emit = defineEmits<{ (e: "update", rows: Record<string, unknown>[]): void; (e: "groups-saved", groups: Record<string, unknown>[], mapping: Map<string, string>): void; (e: "sync-edit", rows: Record<string, unknown>[]): void; (e: "sync-query", rows: Record<string, unknown>[]): void }>();
const gmOpen = ref(false);
function fname(id: unknown) { const f = props.fields.find((x) => String(x.b_id) === String(id)); return f ? String(f.b_field || "") : String(id || ""); }
function gname(id: unknown) { const g = props.groups.find((x) => String(x.b_id) === String(id)); return g ? String(g.b_name || g.b_i18n || "") : ""; }
function up(idx: number, key: string, value: unknown) { const u = [...props.listConfig]; u[idx] = { ...u[idx], [key]: value }; emit("update", u); }
function onCellChange(row: Record<string, unknown>, dataIndex: string, value: unknown) { const idx = props.listConfig.findIndex((x) => String(x.b_id) === String(row.b_id)); if (idx >= 0) up(idx, dataIndex, value); }
function syncEdit() {
const e = props.listConfig.filter((r) => Number(r.b_visible) === 1);
const eid = new Set(props.editConfig.map((r) => String(r.b_field_id)));
const nr = e.filter((r) => !eid.has(String(r.b_field_id))).map((r) => ({ b_id: (r as any).b_id, b_module_id: props.moduleId, b_field_id: r.b_field_id, b_required: 0, b_readonly: 0, b_disabled: 0, b_visible: 1, b_group_id: r.b_group_id || null, b_xh: r.b_xh || 0 }));
if (nr.length) emit("sync-edit", [...props.editConfig, ...nr]);
}
function syncQuery() {
const e = props.listConfig.filter((r) => Number(r.b_visible) === 1);
const qid = new Set(props.queryConfig.map((r) => String(r.b_field_id)));
const nr = e.filter((r) => !qid.has(String(r.b_field_id))).map((r) => ({ b_id: (r as any).b_id, b_module_id: props.moduleId, b_field_id: r.b_field_id, b_component: "input", b_operator: "=", b_default_value: null, b_xh: r.b_xh || 0, b_canuse: 1 }));
if (nr.length) emit("sync-query", [...props.queryConfig, ...nr]);
}
function onRowOrderChange(rows: Record<string, unknown>[]) { const reordered = rows.map((x, i) => ({ ...x, b_xh: (i + 1) * 10 })); emit("update", reordered); }
function fieldCell(row: Record<string, unknown>) { return h("span", { class: "text-[13px]" }, fname(row.b_field_id)); }
function groupCell(row: Record<string, unknown>) { return h("span", { class: "text-[13px] text-muted-foreground" }, gname(row.b_group_id)); }
const columns = ref([
{ dataIndex: "b_field_id", title: "字段", width: 130, customCell: ({ row }: { row: Record<string, unknown> }) => fieldCell(row) },
{ dataIndex: "b_width", title: "列宽", width: 90, editor: { type: "input", inputType: "number", onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_width", Number(v)) } },
{ dataIndex: "b_group_id", title: "分组", width: 100, customCell: ({ row }: { row: Record<string, unknown> }) => groupCell(row) },
{ dataIndex: "b_visible", title: "显示", width: 70, editor: { type: "checkbox", onChange: (v: number, row: Record<string, unknown>) => onCellChange(row, "b_visible", v) } },
{ dataIndex: "b_xh", title: "排序", width: 70, editor: { type: "input", inputType: "number", onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_xh", Number(v)) } },
]);
</script>
<script lang="ts">export default { name: "ModuleListConfigPanel" };</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex shrink-0 items-center justify-between">
<h3 class="text-[13px] font-semibold">列表配置</h3>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" class="h-7 text-[12px]" :disabled="loading" @click="gmOpen = true"><FolderTree :size="12" class="mr-1" />分组</Button>
<Button variant="outline" size="sm" class="h-7 text-[12px]" :disabled="loading" @click="syncEdit"><ArrowRightLeft :size="12" class="mr-1" />同步编辑</Button>
<Button variant="outline" size="sm" class="h-7 text-[12px]" :disabled="loading" @click="syncQuery"><ArrowRightLeft :size="12" class="mr-1" />同步查询</Button>
</div>
</div>
<FmsTable
class="min-h-0 flex-1"
v-model:columns="columns"
:data-source="listConfig"
row-key="b_id"
:editable="!loading"
:virtual="false"
:row-draggable="!loading"
@row-order-change="onRowOrderChange"
/>
<FieldGroupManager :open="gmOpen" :module-id="moduleId" :groups="groups" :referenced-group-ids="referencedGroupIds" @update:open="(v: boolean) => gmOpen = v" @saved="(g, m) => emit('groups-saved', g, m)" />
</div>
</template>
@@ -0,0 +1,79 @@
<script setup lang="ts">
import { h, ref, computed } from "vue";
import { Trash2, Globe, Plus } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import I18nQuickSetModal from "./I18nQuickSetModal.vue";
import { buildModuleItemI18nKey } from "@/utils/i18n";
import { createTempId } from "@/utils/tempId";
const props = defineProps<{ moduleId: string; moduleCode: string; powers: Record<string, unknown>[]; loading?: boolean }>();
const emit = defineEmits<{ (e: "update", powers: Record<string, unknown>[]): void }>();
const i18nModalOpen = ref(false); const i18nTargetKey = ref(""); const i18nDefaultName = ref("");
function openI18nModal(p: Record<string, unknown>) { i18nTargetKey.value = String(p.b_i18n || ""); i18nDefaultName.value = String(p.b_name || p.b_code || ""); i18nModalOpen.value = true; }
function addPower() {
const u = [...props.powers];
u.push({ b_id: createTempId(), b_module_id: props.moduleId, b_code: "", b_name: "", b_i18n: null, b_xh: (u.length + 1) * 10, b_canuse: 1 });
emit("update", u);
}
function updateField(idx: number, key: string, value: unknown) {
const u = [...props.powers]; u[idx] = { ...u[idx], [key]: value };
if (key === "b_code") u[idx].b_i18n = buildModuleItemI18nKey("power", props.moduleCode, String(value));
emit("update", u);
}
function deletePower(idx: number) { const u = [...props.powers]; u.splice(idx, 1); emit("update", u); }
function onCellChange(row: Record<string, unknown>, dataIndex: string, value: unknown) {
const idx = props.powers.findIndex((x) => String(x.b_id) === String(row.b_id));
if (idx >= 0) updateField(idx, dataIndex, value);
}
const selectedRowKeys = ref<string[]>([]);
function onSelectionChange(keys: (string | number)[]) { if (props.loading) return; selectedRowKeys.value = keys.map(String); }
function batchDelete() { emit("update", props.powers.filter((p) => !selectedRowKeys.value.includes(String(p.b_id)))); selectedRowKeys.value = []; }
function i18nCell(row: Record<string, unknown>) {
return h("div", { class: "flex items-center gap-1" }, [
h("span", { class: "flex-1 truncate text-[12px] text-muted-foreground" }, String(row.b_i18n ?? "")),
h("button", {
type: "button",
class: "inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground",
title: "设置多语言",
onClick: (e: MouseEvent) => { e.stopPropagation(); openI18nModal(row); },
}, [h(Globe, { size: 12 })]),
]);
}
const rowSelection = computed(() => ({ selectedRowKeys: selectedRowKeys.value, onChange: onSelectionChange }));
const columns = ref([
{ dataIndex: "b_code", title: "操作编码", width: 130, editor: { type: "input", onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_code", v) } },
{ dataIndex: "b_name", title: "名称", width: 110, editor: { type: "input", onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_name", v) } },
{ dataIndex: "b_i18n", title: "多语言键", width: 170, customCell: ({ row }: { row: Record<string, unknown> }) => i18nCell(row) },
{ dataIndex: "b_xh", title: "排序", width: 70, editor: { type: "input", inputType: "number", onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_xh", Number(v)) } },
{ dataIndex: "b_canuse", title: "启用", width: 60, editor: { type: "checkbox", onChange: (v: number, row: Record<string, unknown>) => onCellChange(row, "b_canuse", v) } },
{ dataIndex: "actions", title: "操作", width: 60, align: "center", actions: [{ type: "delete", danger: true, onClick: (row: Record<string, unknown>) => { const i = props.powers.findIndex((x) => String(x.b_id) === String(row.b_id)); if (i >= 0) deletePower(i); } }] },
]);
</script>
<script lang="ts">export default { name: "ModulePowersPanel" };</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex shrink-0 items-center justify-between">
<h3 class="text-[13px] font-semibold">权限配置</h3>
<div class="flex items-center gap-2">
<Button v-if="selectedRowKeys.length" variant="destructive" size="sm" class="h-7 text-[12px]" @click="batchDelete"><Trash2 :size="12" class="mr-1" />批量删除</Button>
<Button variant="outline" size="sm" class="h-7 text-[12px]" :disabled="loading" @click="addPower"><Plus :size="12" class="mr-1" />新增权限</Button>
</div>
</div>
<FmsTable
class="min-h-0 flex-1"
v-model:columns="columns"
:data-source="powers"
row-key="b_id"
:editable="!loading"
:virtual="false"
:row-selection="rowSelection"
/>
<I18nQuickSetModal :open="i18nModalOpen" :i18n-key="i18nTargetKey" :default-value="i18nDefaultName" @update:open="(v: boolean) => i18nModalOpen = v" />
</div>
</template>
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { h, ref } from "vue";
import { Plus } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import FmsTable from "@/components/fms-table/FmsTable.vue";
import { createTempId } from "@/utils/tempId";
const props = defineProps<{ moduleId: string; queryConfig: Record<string, unknown>[]; fields: Record<string, unknown>[]; loading?: boolean }>();
const emit = defineEmits<{ (e: "update", rows: Record<string, unknown>[]): void }>();
const COMPS = [
{ value: "input", label: "文本框" },{ value: "select", label: "下拉选择" },{ value: "date", label: "日期" },{ value: "datetime", label: "日期时间" },{ value: "number", label: "数字" },{ value: "checkbox", label: "复选框" },{ value: "month", label: "月份" },{ value: "year", label: "年份" },
];
const OPS = [
{ value: "=", label: "等于" },{ value: "!=", label: "不等于" },{ value: ">", label: "大于" },{ value: ">=", label: "大于等于" },{ value: "<", label: "小于" },{ value: "<=", label: "小于等于" },{ value: "like", label: "包含" },{ value: "in", label: "在区间" },{ value: "between", label: "之间" },
];
function fname(id: unknown) { const f = props.fields.find((x) => String(x.b_id) === String(id)); return f ? String(f.b_field || "") : String(id || ""); }
function addRow() {
const en = props.fields.filter((f) => Number(f.b_canuse) === 1);
if (!en.length) return;
const f = en[0];
const u = [...props.queryConfig];
u.push({ b_id: createTempId(), b_module_id: props.moduleId, b_field_id: f.b_id, b_component: "input", b_operator: "=", b_default_value: "", b_xh: (u.length + 1) * 10, b_canuse: 1 });
emit("update", u);
}
function up(idx: number, key: string, value: unknown) { const u = [...props.queryConfig]; u[idx] = { ...u[idx], [key]: value }; emit("update", u); }
function onCellChange(row: Record<string, unknown>, dataIndex: string, value: unknown) { const idx = props.queryConfig.findIndex((x) => String(x.b_id) === String(row.b_id)); if (idx >= 0) up(idx, dataIndex, value); }
function onRowOrderChange(rows: Record<string, unknown>[]) { const reordered = rows.map((x, i) => ({ ...x, b_xh: (i + 1) * 10 })); emit("update", reordered); }
function fieldCell(row: Record<string, unknown>) { return h("span", { class: "text-[13px]" }, fname(row.b_field_id)); }
const columns = ref([
{ dataIndex: "b_field_id", title: "字段", width: 130, customCell: ({ row }: { row: Record<string, unknown> }) => fieldCell(row) },
{ dataIndex: "b_component", title: "组件", width: 110, editor: { type: "select", options: COMPS, allowClear: false, onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_component", v) } },
{ dataIndex: "b_operator", title: "操作符", width: 100, editor: { type: "select", options: OPS, allowClear: false, onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_operator", v) } },
{ dataIndex: "b_default_value", title: "默认值", width: 110, editor: { type: "input", onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_default_value", v) } },
{ dataIndex: "b_canuse", title: "启用", width: 60, editor: { type: "checkbox", onChange: (v: number, row: Record<string, unknown>) => onCellChange(row, "b_canuse", v) } },
{ dataIndex: "b_xh", title: "排序", width: 70, editor: { type: "input", inputType: "number", onChange: (v: string, row: Record<string, unknown>) => onCellChange(row, "b_xh", Number(v)) } },
]);
</script>
<script lang="ts">export default { name: "ModuleQueryConfigPanel" };</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="flex shrink-0 items-center justify-between">
<h3 class="text-[13px] font-semibold">查询配置</h3>
<Button variant="outline" size="sm" class="h-7 text-[12px]" :disabled="loading" @click="addRow"><Plus :size="12" class="mr-1" />新增查询条件</Button>
</div>
<FmsTable
class="min-h-0 flex-1"
v-model:columns="columns"
:data-source="queryConfig"
row-key="b_id"
:editable="!loading"
:virtual="false"
:row-draggable="!loading"
@row-order-change="onRowOrderChange"
/>
</div>
</template>
@@ -1,128 +1,608 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
Info, Table2, List, Pencil, Search as SearchIcon, Hash, Shield, Globe,
} from "@lucide/vue";
import { toast } from "vue-sonner";
import { Button } from "@/components/ui/button";
import { useConfirm } from "@/composables/useConfirm";
import { Switch } from "@/components/ui/switch";
import { Input } from "@/components/ui/input";
import FmsTree from "@/components/FmsTree.vue";
import FmsLoadingOverlay from "@/components/FmsLoadingOverlay.vue";
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from "@/services/api";
import { usePermissionStore } from "@/stores/permissions";
import {
cloneData, diffRows, allocateTemporaryIds, remapForeignKeys, tableChange, hasChanges,
} from "@/utils/dataChanges";
import { createTempId } from "@/utils/tempId";
import { buildTree } from "@/utils/tree";
import {
buildModuleI18nKey, buildModuleItemI18nKey, syncModuleItemI18nKeys, validateModuleCode,
} from "@/utils/i18n";
import ModuleBasicPanel from "./components/ModuleBasicPanel.vue";
import ModuleFieldsPanel from "./components/ModuleFieldsPanel.vue";
import ModuleListConfigPanel from "./components/ModuleListConfigPanel.vue";
import ModuleEditConfigPanel from "./components/ModuleEditConfigPanel.vue";
import ModuleQueryConfigPanel from "./components/ModuleQueryConfigPanel.vue";
import ModuleAutoCodePanel from "./components/ModuleAutoCodePanel.vue";
import ModulePowersPanel from "./components/ModulePowersPanel.vue";
import ModuleI18nPanel from "./components/ModuleI18nPanel.vue";
import type { TreeNode, ContextMenuItemDef } from "@/components/FmsTreeNode.vue";
import type { SectionKey, ManagedI18nItem } from "@/types/module";
const expandedKeys = ref<string[]>(["__fms_root__"]);
const selectedKeys = ref<string[]>([]);
const selectedNode = ref<TreeNode | null>(null);
const permissionStore = usePermissionStore();
const { confirm } = useConfirm();
const treeData: TreeNode[] = [
{
key: "sys",
title: "系统管理",
children: [
{
key: "sys_user",
title: "用户管理",
children: [
{ key: "sys_user_list", title: "用户列表" },
{ key: "sys_user_role", title: "角色配置" },
],
},
{
key: "sys_log",
title: "日志管理",
children: [
{ key: "sys_log_login", title: "登录日志" },
{ key: "sys_log_op", title: "操作日志" },
],
},
{ key: "sys_i18n", title: "国际化管理" },
],
},
{
key: "biz",
title: "业务模块",
children: [
{
key: "biz_order",
title: "订单管理",
children: [
{ key: "biz_order_list", title: "订单列表" },
{ key: "biz_order_setting", title: "订单配置" },
],
},
{ key: "biz_product", title: "产品管理" },
],
},
{
key: "report",
title: "报表中心",
children: [
{ key: "report_daily", title: "日报" },
{ key: "report_monthly", title: "月报" },
],
},
];
function buildContextMenu(node: TreeNode): ContextMenuItemDef[] {
return [
{ key: "add_child", label: "新增子节点" },
{ key: "edit", label: "编辑" },
{ key: "delete", label: "删除" },
];
// ---- Tab config ----
interface TabItem { key: SectionKey; label: string; icon: typeof Info }
function sectionTabs(moduleType: unknown): TabItem[] {
const tabs: TabItem[] = [{ key: "basic", label: "基本信息", icon: Info }];
if (moduleType === "data") {
tabs.push(
{ key: "fields", label: "字段定义", icon: Table2 },
{ key: "list", label: "列表配置", icon: List },
{ key: "edit", label: "编辑配置", icon: Pencil },
{ key: "query", label: "查询配置", icon: SearchIcon },
{ key: "auto-code", label: "自动编码", icon: Hash },
);
}
if (moduleType === "page") {
tabs.push({ key: "powers", label: "权限配置", icon: Shield });
}
if (moduleType !== "directory") {
tabs.push({ key: "i18n", label: "多语言", icon: Globe });
}
return tabs;
}
function onSelect(_keys: string[], info: { node: TreeNode }) {
selectedNode.value = info.node;
// ---- State ----
const treeData = ref<Record<string, unknown>[]>([]);
const treeLoading = ref(false);
const expandedTreeKeys = ref<string[]>([]);
const selectedModuleId = ref<string | null>(null);
const skipLoad = ref(false);
const treeSearch = ref("");
function filterTree(nodes: Record<string, unknown>[], keyword: string): Record<string, unknown>[] {
if (!keyword) return nodes;
const kw = keyword.toLowerCase();
return nodes.reduce<Record<string, unknown>[]>((acc, node) => {
const title = String(node.b_name || node.b_code || "").toLowerCase();
const children = filterTree((node.children as Record<string, unknown>[]) || [], kw);
if (title.includes(kw) || children.length) acc.push({ ...node, children });
return acc;
}, []);
}
function onMenuClick({ key, node }: { key: string; node: TreeNode }) {
switch (key) {
case "add_child":
toast.info(`新增子节点 — 父节点: ${node.title}`);
break;
case "edit":
toast.info(`编辑节点: ${node.title}`);
break;
case "delete":
toast.warning(`删除节点: ${node.title}`);
break;
const filteredTreeData = computed(() => filterTree(treeData.value, treeSearch.value));
function buildTreeContextMenu(node: TreeNode): ContextMenuItemDef[] {
const items: ContextMenuItemDef[] = [];
if (!node || node.isFmsRoot) {
if (canManage("create")) items.push({ key: "add-directory", label: "添加目录" });
return items;
}
const type = node.b_module_type as string;
if (type === "directory" && canManage("create")) {
items.push({ key: "add-directory", label: "添加目录" }, { key: "add-page", label: "添加页面" });
} else if (["page", "data_category"].includes(type) && canManage("create")) {
items.push({ key: "add-data_category", label: "添加数据分类" }, { key: "add-data", label: "添加数据" });
}
if (canManage("delete")) items.push({ key: "delete", label: "删除" });
return items;
}
function canManage(actionCode: string): boolean {
return permissionStore.canPower?.("m_metadata", actionCode) ?? false;
}
// ---- Originals / Drafts ----
const originals = ref({
module: null as Record<string, unknown> | null,
fields: [] as Record<string, unknown>[], groups: [] as Record<string, unknown>[],
listConfig: [] as Record<string, unknown>[], editConfig: [] as Record<string, unknown>[],
queryConfig: [] as Record<string, unknown>[], autoCode: [] as Record<string, unknown>[],
powers: [] as Record<string, unknown>[],
});
const drafts = ref(cloneData(originals.value));
const configLoading = ref(false);
const configSaving = ref(false);
const configSyncing = ref(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const i18nPanelRef = ref<any>(null);
const loadedModuleId = ref<string | null>(null);
function setI18nPanelRef(instance: unknown) { i18nPanelRef.value = instance; }
const selectedModule = computed(() => drafts.value.module);
const isDataModule = computed(() => selectedModule.value?.b_module_type === "data");
const isPageModule = computed(() => selectedModule.value?.b_module_type === "page");
const isDirectoryModule = computed(() => selectedModule.value?.b_module_type === "directory");
const moduleTypeLabel = computed(() => {
const map: Record<string, string> = { directory: "DIR", page: "PAGE", data: "DATA", data_category: "CAT" };
return map[String(selectedModule.value?.b_module_type || "")] || "";
});
const activeSection = ref<SectionKey>("basic");
const tabs = computed(() => sectionTabs(selectedModule.value?.b_module_type));
watch(tabs, (list) => {
if (!list.some((t) => t.key === activeSection.value)) activeSection.value = "basic";
});
const busy = computed(() => configLoading.value || configSaving.value || configSyncing.value);
const isDirty = computed(() => {
const m = selectedModule.value;
if (!m) return false;
if (unsavedModuleIds.has(String(m.b_id))) return true;
return JSON.stringify(drafts.value) !== JSON.stringify(originals.value);
});
function buildI18nItems(managedSource: typeof originals.value, plainSource: typeof drafts.value): ManagedI18nItem[] {
const items = new Map<string, ManagedI18nItem>();
const add = (item: ManagedI18nItem) => {
if (!item.key) return;
const existing = items.get(item.key);
items.set(item.key, existing?.managed ? existing : { ...item, key: item.key });
};
const module = managedSource.module;
if (module?.b_code) {
add({ id: `module:${module.b_id}`, key: buildModuleI18nKey(module.b_module_type as string, module.b_code) || "",
sourceKey: module.b_i18n as string, defaultValue: String(module.b_name || module.b_code), managed: true });
}
for (const field of managedSource.fields || []) {
add({ id: `field:${field.b_id}`, key: buildModuleItemI18nKey("field", module?.b_code, field.b_field) || "",
sourceKey: field.b_i18n as string, defaultValue: String(field.b_name || field.b_field), managed: true });
}
for (const power of managedSource.powers || []) {
add({ id: `power:${power.b_id}`, key: buildModuleItemI18nKey("power", module?.b_code, power.b_code) || "",
sourceKey: power.b_i18n as string, defaultValue: String(power.b_name || power.b_code), managed: true });
}
for (const section of ["groups", "listConfig"] as const) {
for (const row of plainSource[section] || []) add({ key: row.b_i18n as string, managed: false });
}
return [...items.values()];
}
const i18nItems = computed(() => buildI18nItems(originals.value, drafts.value));
const finalI18nItems = computed(() => buildI18nItems(drafts.value, drafts.value));
const referencedGroupIds = computed(() => {
const ids = new Set<string>();
for (const s of ["listConfig", "editConfig"] as const)
for (const row of drafts.value[s] || []) if (row.b_group_id != null) ids.add(String(row.b_group_id));
return [...ids];
});
const parentModuleName = computed(() => {
const pid = selectedModule.value?.b_parent_id;
if (!pid) return "";
const find = (nodes: Record<string, unknown>[]): string => {
for (const n of nodes || []) {
if (String(n.b_id) === String(pid)) return String(n.b_name || n.b_code);
const c = find((n.children as Record<string, unknown>[]) || []);
if (c) return c;
}
return "";
};
return find(treeData.value);
});
// ---- Sidebar Resize ----
const leftPanelWidth = ref(260);
const isResizing = ref(false);
let resizeStartX = 0, resizeStartWidth = 0;
function onResizeStart(e: MouseEvent) { e.preventDefault(); isResizing.value = true; resizeStartX = e.clientX; resizeStartWidth = leftPanelWidth.value; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; }
function onResizeMove(e: MouseEvent) { if (!isResizing.value) return; leftPanelWidth.value = Math.min(440, Math.max(210, resizeStartWidth + (e.clientX - resizeStartX))); }
function onResizeEnd() { if (!isResizing.value) return; isResizing.value = false; document.body.style.cursor = ""; document.body.style.userSelect = ""; }
function onBeforeWindowUnload(e: BeforeUnloadEvent) { if (isDirty.value) { e.preventDefault(); e.returnValue = ""; } }
onMounted(() => { document.addEventListener("mousemove", onResizeMove); document.addEventListener("mouseup", onResizeEnd); window.addEventListener("beforeunload", onBeforeWindowUnload); });
onBeforeUnmount(() => { document.removeEventListener("mousemove", onResizeMove); document.removeEventListener("mouseup", onResizeEnd); window.removeEventListener("beforeunload", onBeforeWindowUnload); });
// ---- Module Tree ----
function buildModuleTree(modules: Record<string, unknown>[]) {
const list = modules.map((m) => ({ ...m, key: String(m.b_id), title: String(m.b_name || m.b_code), b_parent_id: m.b_parent_id != null ? String(m.b_parent_id) : null }));
return buildTree(list as never) as unknown as Record<string, unknown>[];
}
function findNode(key: string): Record<string, unknown> | null {
const walk = (nodes: Record<string, unknown>[]): Record<string, unknown> | null => {
for (const n of nodes || []) { if (String(n.b_id) === String(key)) return n; const c = walk((n.children as Record<string, unknown>[]) || []); if (c) return c; }
return null;
};
return walk(treeData.value);
}
function updateSavedTreeNode(moduleData: Record<string, unknown>) {
let node: Record<string, unknown> | null = findNode(String(moduleData.b_id));
if (!node) {
node = { ...cloneData(moduleData), children: [] };
const parent = moduleData.b_parent_id ? findNode(String(moduleData.b_parent_id)) : null;
if (parent) { if (!parent.children) parent.children = []; (parent.children as Record<string, unknown>[]).push(node); }
else treeData.value.push(node);
}
const children = (node as Record<string, unknown>).children;
Object.assign(node, cloneData(moduleData), { key: String(moduleData.b_id), title: String(moduleData.b_name || moduleData.b_code) });
if (children) (node as Record<string, unknown>).children = children;
}
async function loadTree() {
treeLoading.value = true;
try {
const res = await loadDataApi("s_module", "b_canuse = 1", "b_xh ASC, b_id ASC");
treeData.value = buildModuleTree((res.data || []) as Record<string, unknown>[]);
unsavedModuleIds.clear();
} finally { treeLoading.value = false; }
}
const unsavedModuleIds = new Set<string>();
// ---- Load Module Config ----
async function loadModuleConfig(moduleId: string) {
const id = String(moduleId);
if (String(selectedModuleId.value) !== id) return;
configLoading.value = true;
try {
const node = findNode(id);
const moduleType = node?.["b_module_type"] as string | undefined;
let moduleData: Record<string, unknown>[] = [], fieldsData: Record<string, unknown>[] = [];
let groupsData: Record<string, unknown>[] = [], listData: Record<string, unknown>[] = [], editData: Record<string, unknown>[] = [];
let queryData: Record<string, unknown>[] = [], autoCodeData: Record<string, unknown>[] = [], powersData: Record<string, unknown>[] = [];
if (moduleType === "data") {
const res = await Promise.all([
loadDataApi("s_module", `b_id = ${id}`),
loadDataApi("s_module_field", `b_module_id = ${id}`, "b_id ASC"),
loadDataApi("s_module_field_group", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_list", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_edit", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_field_query", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
loadDataApi("s_module_auto_code", `b_module_id = ${id}`, "b_id ASC"),
]);
[moduleData, fieldsData, groupsData, listData, editData, queryData, autoCodeData] = res.map((r) => (r.data || []) as Record<string, unknown>[]);
} else if (moduleType === "page") {
const res = await Promise.all([
loadDataApi("s_module", `b_id = ${id}`),
loadDataApi("s_module_power", `b_module_id = ${id}`, "b_xh ASC, b_id ASC"),
]);
[moduleData, powersData] = res.map((r) => (r.data || []) as Record<string, unknown>[]);
} else {
const r = await loadDataApi("s_module", `b_id = ${id}`);
moduleData = (r.data || []) as Record<string, unknown>[];
}
if (String(selectedModuleId.value) !== id) return;
originals.value = { module: moduleData[0] || null, fields: fieldsData, groups: groupsData, listConfig: listData, editConfig: editData, queryConfig: queryData, autoCode: autoCodeData, powers: powersData };
drafts.value = cloneData(originals.value);
loadedModuleId.value = id;
activeSection.value = "basic";
} catch (e: unknown) {
if (String(selectedModuleId.value) === String(moduleId)) { loadedModuleId.value = null; drafts.value.module = null; toast.error("模块配置加载失败:" + ((e as Error).message || e)); }
} finally {
if (String(selectedModuleId.value) === String(moduleId)) configLoading.value = false;
}
}
async function onTreeSelect(keys: string[], info?: { node?: TreeNode }) {
if (busy.value) return;
if (info?.node?.isFmsRoot) {
if (isDirty.value && !(await confirm({ title: "放弃未保存的修改", description: "当前模块有未保存的修改,切换到根节点将丢弃这些修改。", cancelText: "取消", confirmText: "仍然切换", danger: true }))) return;
selectedModuleId.value = null; return;
}
const nk = info?.node?.key ?? keys[0];
if (nk == null) return;
if (String(nk) === String(selectedModuleId.value)) return;
if (isDirty.value && !(await confirm({ title: "放弃未保存的修改", description: "当前模块有未保存的修改,切换模块将丢弃这些修改。", cancelText: "取消", confirmText: "仍然切换", danger: true }))) return;
selectedModuleId.value = String(nk);
}
watch(selectedModuleId, (id) => {
if (!id) { configLoading.value = false; loadedModuleId.value = null; drafts.value.module = null; return; }
if (skipLoad.value) { skipLoad.value = false; configLoading.value = false; return; }
loadedModuleId.value = null;
void loadModuleConfig(id);
});
function onUpdate(section: string, newData: Record<string, unknown>[]) { (drafts.value as Record<string, unknown>)[section] = newData; }
// ---- Helpers ----
function isFieldEnabled(f: Record<string, unknown>) { return f && Number(f.b_canuse) === 1; }
function applyDisabledFieldDefaults(prev: Record<string, unknown>[], next: Record<string, unknown>[]) {
const prevStates = new Map<string, boolean>(); prev.forEach((f) => { if (f.b_id) prevStates.set(String(f.b_id), isFieldEnabled(f)); });
const disabledIds = new Set<string>(); next.forEach((f) => { if (f.b_id && prevStates.get(String(f.b_id)) && !isFieldEnabled(f)) disabledIds.add(String(f.b_id)); });
if (!disabledIds.size) return;
for (const row of drafts.value.listConfig) if (disabledIds.has(String(row.b_field_id))) row.b_visible = 0;
for (const row of drafts.value.editConfig) if (disabledIds.has(String(row.b_field_id))) row.b_visible = 0;
for (const row of drafts.value.queryConfig) if (disabledIds.has(String(row.b_field_id))) row.b_canuse = 0;
}
function onFieldsUpdate(newFields: Record<string, unknown>[]) { applyDisabledFieldDefaults(drafts.value.fields, newFields); drafts.value.fields = newFields; }
function onAutoCodeUpdate(rows: Record<string, unknown>[]) { drafts.value.autoCode = rows; }
function validateAutoCode(): string {
const rows = drafts.value.autoCode || [];
const rule = rows.length ? rows[0] : null;
if (!rule || Number(rule.b_canuse) !== 1) return "";
if (!String(selectedModule.value?.b_savetable || "").trim()) return "启用自动编码前请先配置保存表";
const field = (drafts.value.fields || []).find((f) => String(f.b_id) === String(rule.b_field_id));
if (!field || Number(field.b_canuse) !== 1) return "请选择已启用的业务编号字段";
const width = Number(rule.b_sequence_width);
if (!Number.isInteger(width) || width < 1 || width > 18) return "流水号位数必须是 1 到 18 之间的整数";
return "";
}
function onGroupsSaved(groups: Record<string, unknown>[], idMapping?: Map<string, string>) {
if (idMapping?.size) { remapForeignKeys(drafts.value.listConfig, ["b_group_id"], idMapping); remapForeignKeys(drafts.value.editConfig, ["b_group_id"], idMapping); }
originals.value.groups = cloneData(groups); drafts.value.groups = cloneData(groups);
}
// ---- Add Module ----
async function onAddModule(parentId: string | null, moduleType = "directory") {
if (!canManage("create")) return;
const res = await nextIdApi();
const newId = String(res.data);
const m: Record<string, unknown> = { b_id: newId, b_code: "", b_parent_id: parentId ? String(parentId) : null, b_name: "", b_i18n: null, b_module_type: moduleType, b_route: null, b_viewtable: null, b_savetable: null, b_keyfield: null, b_orderfield: null, b_canmenu: 0, b_canuse: 1, b_xh: 0, b_icon: null };
originals.value = { module: cloneData(m), fields: [], groups: [], listConfig: [], editConfig: [], queryConfig: [], autoCode: [], powers: [] };
drafts.value = cloneData(originals.value);
skipLoad.value = true; selectedModuleId.value = newId; loadedModuleId.value = newId; activeSection.value = "basic";
unsavedModuleIds.add(newId);
loadTree().catch(() => {});
}
function onTreeMenuClick(p: { key: string; node: TreeNode }) {
if (busy.value) return;
if (p.key.startsWith("add-")) { onAddModule(p.node.isFmsRoot ? null : p.node.key, p.key.replace("add-", "")); }
else if (p.key === "delete") { onDeleteNode(p.node); }
}
async function onDeleteNode(node: TreeNode) {
if (hasChildNode(String(node.key))) { toast.error("请先删除子节点"); return; }
const ok = await confirm({ title: "删除模块", description: `确定删除「${node.title}」?此操作不可恢复,且会删除其下所有子模块与配置。`, confirmText: "删除", danger: true });
if (!ok) return;
await onDeleteNodeById(String(node.key));
}
function hasChildNode(id: string) { const n = findNode(id); return !!(n?.children && (n.children as unknown[]).length); }
async function onDeleteNodeById(id: string) {
if (!canManage("delete") || hasChildNode(id)) return;
try {
const del = (t: string) => ({ table: t, key_field: "b_module_id", deletes: [{ b_module_id: id }] });
await saveObjectApi([del("b_user_module"), del("b_user_power"), del("s_module_field_list"), del("s_module_field_edit"), del("s_module_field_query"), del("s_module_auto_code"), del("s_module_power"), del("s_module_field"), del("s_module_field_group"), del("b_i18n"), { table: "s_module", key_field: "b_id", deletes: [{ b_id: id }] }]);
if (selectedModuleId.value === id) { selectedModuleId.value = null; drafts.value.module = null; }
await loadTree();
} catch (e: unknown) { toast.error("删除失败:" + ((e as Error).message || e)); }
}
// ---- Save All ----
async function saveAll() {
if (!canManage("update") || !selectedModule.value || busy.value) return;
if (!loadedModuleId.value || String(loadedModuleId.value) !== String(selectedModule.value.b_id)) return;
const moduleId = String(selectedModule.value.b_id);
configSaving.value = true;
try {
const autoCodeErr = validateAutoCode(); if (autoCodeErr) { toast.error(autoCodeErr); return; }
const md = drafts.value.module, mo = originals.value.module;
const code = String(md?.b_code || "").trim();
if (!code) { toast.error("请填写编码"); return; }
const codeErr = validateModuleCode(code); if (codeErr) { toast.error(`编码格式不正确:${codeErr}`); return; }
if (!String(md?.b_name || "").trim()) { toast.error("请填写名称"); return; }
if (md?.b_module_type === "page" && !String(md?.b_route || "").trim()) { toast.error("请填写页面路由"); return; }
if (md?.b_module_type === "data" && !String(md?.b_viewtable || "").trim()) { toast.error("请填写查询视图"); return; }
syncModuleItemI18nKeys(drafts.value.fields, "field", md?.b_code, "b_field");
syncModuleItemI18nKeys(drafts.value.powers, "power", md?.b_code, "b_code");
if (md) md.b_i18n = buildModuleI18nKey(md.b_module_type as string, md.b_code as string);
const saveReqs: Record<string, unknown>[] = [];
const isNew = unsavedModuleIds.has(moduleId);
if (md && (isNew || JSON.stringify(md) !== JSON.stringify(mo))) {
const mr: Record<string, unknown> = { table: "s_module", key_field: "b_id" };
if (isNew) mr.inserts = [md]; else mr.updates = [md];
saveReqs.push(mr);
}
const subTables: [string, keyof typeof drafts.value][] = [["s_module_field", "fields"], ["s_module_field_list", "listConfig"], ["s_module_field_edit", "editConfig"], ["s_module_field_query", "queryConfig"], ["s_module_auto_code", "autoCode"], ["s_module_power", "powers"]];
for (const [table, section] of subTables) {
const cur = drafts.value[section] || [], prev = originals.value[section] || [];
const changes = diffRows(cur as never, prev as never);
if (table === "s_module_auto_code") { changes.updates = changes.updates.map((row: Record<string, unknown>) => { const { b_current_period: _, b_current_value: __, ...c } = row; return c; }) as never; }
const req = tableChange(table, changes);
if (hasChanges(req)) {
await allocateTemporaryIds([req.inserts || [], req.updates || [], req.deletes || []]);
if (table === "s_module_power" && changes.deletes.length) saveReqs.push({ table: "b_user_power", key_field: "b_power_id", deletes: changes.deletes.map(({ b_id }: { b_id: string }) => ({ b_power_id: String(b_id) })) });
saveReqs.push(req as unknown as Record<string, unknown>);
}
}
if (i18nPanelRef.value?.reconcileManagedRows) i18nPanelRef.value.reconcileManagedRows(finalI18nItems.value);
if (i18nPanelRef.value?.buildSaveRequest) {
const r = await i18nPanelRef.value.buildSaveRequest();
if (r) saveReqs.push(r);
}
if (!saveReqs.length) { toast.error("没有需要保存的变更"); return; }
await saveObjectApi(saveReqs as never);
originals.value = cloneData(drafts.value);
if (i18nPanelRef.value) i18nPanelRef.value.markSaved();
unsavedModuleIds.delete(moduleId);
updateSavedTreeNode(md as Record<string, unknown>);
permissionStore.load().catch(() => {});
toast.success("保存成功");
} catch (e: unknown) { toast.error("保存失败:" + ((e as Error).message || e)); }
finally { configSaving.value = false; }
}
// ---- Sync Fields ----
function configuredFieldIds(section: keyof typeof drafts.value): Set<string> {
const ids = new Set<string>();
for (const row of (originals.value[section] as Record<string, unknown>[]) || []) if (row.b_field_id) ids.add(String(row.b_field_id));
for (const row of (drafts.value[section] as Record<string, unknown>[]) || []) if (row.b_field_id) ids.add(String(row.b_field_id));
return ids;
}
function buildMissingDefaultConfigs(module: Record<string, unknown>, fields: Record<string, unknown>[]) {
const mid = String(module.b_id), lIds = configuredFieldIds("listConfig"), eIds = configuredFieldIds("editConfig"), qIds = configuredFieldIds("queryConfig");
const g = { listConfig: [] as Record<string, unknown>[], editConfig: [] as Record<string, unknown>[], queryConfig: [] as Record<string, unknown>[] };
let i = 0;
for (const f of fields) { if (f.b_canuse !== 1) continue; i++; const fid = String(f.b_id), o = i * 10;
if (!lIds.has(fid)) g.listConfig.push({ b_id: createTempId(), b_module_id: mid, b_field_id: fid, b_group_id: null, b_title: null, b_i18n: f.b_i18n || null, b_visible: 1, b_xh: o, b_width: 100, b_sortable: 0 });
if (!eIds.has(fid)) g.editConfig.push({ b_id: createTempId(), b_module_id: mid, b_field_id: fid, b_required: 0, b_readonly: 0, b_disabled: 0, b_visible: 1, b_group_id: null, b_xh: o });
if (!qIds.has(fid)) g.queryConfig.push({ b_id: createTempId(), b_module_id: mid, b_field_id: fid, b_component: "input", b_operator: "=", b_default_value: null, b_xh: o, b_canuse: 1 });
}
return g;
}
async function syncFields() {
const m = selectedModule.value; if (!m) return;
const view = String(m.b_viewtable || "").trim(); if (!view || busy.value) return;
const mid = String(m.b_id);
configSyncing.value = true;
try {
const res = await describeApi(view);
if (!selectedModule.value || String(selectedModule.value.b_id) !== mid) return;
const cols = (res.data || []) as { name: string }[];
const colSet = new Set(cols.map((c) => c.name.toLowerCase()));
const ef = drafts.value.fields || [];
const efMap = new Map<string, Record<string, unknown>>();
for (const f of ef) if (f.b_field) efMap.set(String(f.b_field).toLowerCase(), f);
const merged: Record<string, unknown>[] = cols.map((c) => {
const exist = efMap.get(c.name.toLowerCase());
return exist ? { ...exist, b_canuse: 1 } : { b_id: createTempId(), b_module_id: String(m.b_id), b_field: c.name, b_name: c.name, b_i18n: null, b_type: "input", b_default_value: null, b_options: null, b_canuse: 1 };
});
const removed = new Set<string>();
for (const f of ef) { if (f.b_field && !colSet.has(String(f.b_field).toLowerCase())) removed.add(String(f.b_id)); }
if (removed.size) {
for (const s of ["listConfig", "editConfig", "queryConfig"] as const) drafts.value[s] = (drafts.value[s] || []).filter((r) => !removed.has(String(r.b_field_id)));
if (i18nPanelRef.value?.removeByManagedIds) i18nPanelRef.value.removeByManagedIds(new Set([...removed].map((id) => `field:${id}`)));
}
merged.forEach((f) => { f.b_i18n = buildModuleItemI18nKey("field", m.b_code, f.b_field); });
const changes = diffRows(merged as never, ef as never);
const freq = tableChange("s_module_field", changes);
await allocateTemporaryIds([freq.inserts || [], freq.updates || [], freq.deletes || []]);
applyDisabledFieldDefaults(ef, merged);
const gen = buildMissingDefaultConfigs(m, merged);
for (const s of ["listConfig", "editConfig", "queryConfig"] as const) { if (gen[s].length) drafts.value[s] = [...(drafts.value[s] || []), ...cloneData(gen[s])]; }
drafts.value.fields = cloneData(merged);
toast.success("同步成功");
} catch (e: unknown) { toast.error("字段同步失败:" + ((e as Error).message || e)); }
finally { configSyncing.value = false; }
}
// ---- Init ----
void permissionStore.load().then(loadTree).catch((e: unknown) => toast.error("模块树加载失败:" + ((e as Error).message || e)));
</script>
<template>
<div class="flex h-full gap-4 p-6">
<div class="flex h-full w-full min-w-0 min-h-0 bg-background">
<!-- 左侧树 -->
<div class="w-72 shrink-0 rounded-xl border bg-card p-4">
<h2 class="mb-3 text-sm font-semibold text-foreground">模块树</h2>
<FmsTree
v-model:expanded-keys="expandedKeys"
v-model:selected-keys="selectedKeys"
:tree-data="treeData"
:context-menu="buildContextMenu"
@select="onSelect"
@menu-click="onMenuClick"
>
<template #title="{ node }">
<span class="text-sm">{{ node.title }}</span>
</template>
</FmsTree>
</div>
<aside class="flex shrink-0 flex-col" :style="{ width: leftPanelWidth + 'px' }">
<div class="py-2">
<div class="relative">
<SearchIcon :size="14" class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input v-model="treeSearch" class="h-8 pl-8 text-[13px]" placeholder="搜索模块…" />
</div>
</div>
<div class="flex flex-1 min-h-0 flex-col overflow-hidden pb-2">
<div v-if="treeLoading" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">加载中…</div>
<div v-else class="flex-1 min-h-0 overflow-auto px-1">
<FmsTree
:tree-data="filteredTreeData as TreeNode[]"
v-model:expanded-keys="expandedTreeKeys"
:selected-keys="selectedModuleId ? [selectedModuleId] : []"
:context-menu="busy ? null : buildTreeContextMenu"
@select="onTreeSelect"
@menu-click="onTreeMenuClick"
/>
</div>
</div>
</aside>
<!-- 右侧详情 -->
<div class="flex-1 rounded-xl border bg-card p-4">
<h2 class="mb-3 text-sm font-semibold text-foreground">节点详情</h2>
<div v-if="selectedNode" class="space-y-2 text-sm text-muted-foreground">
<div>
<span class="font-medium text-foreground">Key: </span>
<code class="rounded bg-muted px-1.5 py-0.5 text-xs">{{ selectedNode.key }}</code>
<!-- 拖拽手柄 -->
<div class="w-1 shrink-0 cursor-col-resize bg-transparent transition-colors hover:bg-border/60" @mousedown="onResizeStart" />
<!-- 右侧 -->
<main class="relative flex flex-1 min-w-0 flex-col overflow-hidden">
<FmsLoadingOverlay :loading="configLoading" />
<template v-if="selectedModule">
<!-- Header -->
<div class="flex shrink-0 items-center justify-between bg-background px-5 py-2.5">
<div class="flex items-center gap-2 min-w-0">
<span class="truncate text-[14px] font-semibold">{{ String(selectedModule.b_name || selectedModule.b_code) }}</span>
<span class="inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider text-muted-foreground bg-muted">{{ moduleTypeLabel }}</span>
</div>
<div class="flex items-center gap-3 shrink-0">
<label class="flex cursor-pointer items-center gap-1.5 text-[13px] text-muted-foreground">
<Switch :checked="Number(drafts.module?.b_canuse) === 1" :disabled="busy" @update:checked="(v: boolean) => { if (drafts.module) drafts.module.b_canuse = v ? 1 : 0; }" />启用
</label>
<template v-if="['directory', 'page'].includes(String(selectedModule.b_module_type))">
<label class="flex cursor-pointer items-center gap-1.5 text-[13px] text-muted-foreground">
<Switch :checked="Number(drafts.module?.b_canmenu) === 1" :disabled="busy" @update:checked="(v: boolean) => { if (drafts.module) drafts.module.b_canmenu = v ? 1 : 0; }" />菜单
</label>
</template>
<Button v-if="canManage('update')" size="sm" class="h-7 text-[12px]" :disabled="configLoading || configSyncing" @click="saveAll">保存</Button>
</div>
</div>
<div>
<span class="font-medium text-foreground">Title: </span>
{{ selectedNode.title }}
</div>
<div>
<span class="font-medium text-foreground">子节点数: </span>
{{ selectedNode.children?.length ?? 0 }}
<!-- Tabs -->
<div class="flex flex-1 min-h-0 flex-col overflow-hidden">
<div class="shrink-0 bg-background px-4">
<div class="flex h-9 gap-1 border-b border-border">
<button
v-for="t in tabs"
:key="t.key"
type="button"
class="flex h-9 -mb-px items-center gap-1.5 border-b-2 px-3 text-[13px] transition-colors"
:class="activeSection === t.key
? 'border-primary font-medium text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground'"
@click="activeSection = t.key"
>
<component :is="t.icon" :size="15" />
{{ t.label }}
</button>
</div>
</div>
<!-- Tab Content -->
<div class="flex-1 min-h-0 overflow-hidden p-5">
<div v-show="activeSection === 'basic'" class="h-full min-h-0">
<ModuleBasicPanel
:module-id="String(selectedModule.b_id ?? '')"
:data="drafts.module"
:parent-name="parentModuleName"
:loading="busy"
@update="(v) => drafts.module = v"
/>
</div>
<div v-show="isDataModule && activeSection === 'fields'" class="h-full min-h-0">
<ModuleFieldsPanel :module-id="String(selectedModule.b_id ?? '')" :module-code="String(selectedModule.b_code ?? '')" :fields="drafts.fields" :loading="busy" @update="(v) => onFieldsUpdate(v as any)" @sync-fields="syncFields" />
</div>
<div v-show="isDataModule && activeSection === 'list'" class="h-full min-h-0">
<ModuleListConfigPanel :module-id="String(selectedModule.b_id ?? '')" :list-config="drafts.listConfig" :edit-config="drafts.editConfig" :query-config="drafts.queryConfig" :fields="drafts.fields" :groups="drafts.groups" :referenced-group-ids="referencedGroupIds" :loading="busy" @update="(v) => onUpdate('listConfig', v)" @groups-saved="onGroupsSaved" @sync-edit="(v) => onUpdate('editConfig', v)" @sync-query="(v) => onUpdate('queryConfig', v)" />
</div>
<div v-show="isDataModule && activeSection === 'edit'" class="h-full min-h-0">
<ModuleEditConfigPanel :module-id="String(selectedModule.b_id ?? '')" :edit-config="drafts.editConfig" :list-config="drafts.listConfig" :query-config="drafts.queryConfig" :fields="drafts.fields" :groups="drafts.groups" :referenced-group-ids="referencedGroupIds" :loading="busy" @update="(v) => onUpdate('editConfig', v)" @groups-saved="onGroupsSaved" @sync-list="(v) => onUpdate('listConfig', v)" @sync-query="(v) => onUpdate('queryConfig', v)" />
</div>
<div v-show="isDataModule && activeSection === 'query'" class="h-full min-h-0">
<ModuleQueryConfigPanel :module-id="String(selectedModule.b_id ?? '')" :query-config="drafts.queryConfig" :fields="drafts.fields" :loading="busy" @update="(v) => onUpdate('queryConfig', v)" />
</div>
<div v-show="isDataModule && activeSection === 'auto-code'" class="h-full min-h-0">
<ModuleAutoCodePanel :module-id="String(selectedModule.b_id ?? '')" :auto-code="drafts.autoCode" :fields="drafts.fields" :loading="busy" @update="onAutoCodeUpdate" />
</div>
<div v-show="isPageModule && activeSection === 'powers'" class="h-full min-h-0">
<ModulePowersPanel :module-id="String(selectedModule.b_id ?? '')" :module-code="String(selectedModule.b_code ?? '')" :powers="drafts.powers" :loading="busy" @update="(v) => onUpdate('powers', v)" />
</div>
<div v-show="!isDirectoryModule && activeSection === 'i18n'" class="h-full min-h-0">
<ModuleI18nPanel :ref="setI18nPanelRef" :module-id="String(selectedModule.b_id ?? '')" :i18n-items="i18nItems" :loading="busy" />
</div>
</div>
</div>
</template>
<div v-else class="flex flex-1 items-center justify-center text-sm text-muted-foreground">
请从左侧选择一个模块,或右键新建
</div>
<div v-else class="flex h-40 items-center justify-center text-sm text-muted-foreground">
请在左侧树中选择一个节点
</div>
</div>
</main>
</div>
</template>