20260811165111
This commit is contained in:
1 parent
5adbf59425
commit
ce78653207
34 files changed
+3793
-366
No files matched your search
+148
-2
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 生成本地临时 ID,用于尚未保存到后端的子表新增行。
|
||||
// 使用负数以区别于后端生成的雪花 ID(恒为正),保存时后端会替换为真实 ID。
|
||||
let sequence = 0;
|
||||
|
||||
/**
|
||||
* 生成会话内唯一的临时 ID(负数字符串)。
|
||||
* @returns 形如 "-1"、"-2" 的临时 ID。
|
||||
*/
|
||||
export function createTempId(): string {
|
||||
sequence += 1;
|
||||
return String(-sequence);
|
||||
}
|
||||
@@ -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>
|
||||
+44
@@ -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>
|
||||
+67
@@ -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>
|
||||
+65
@@ -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>
|
||||
+61
@@ -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>
|
||||
Reference in new issue
Block a user