20260827173205
This commit is contained in:
1 parent
60cacfa8fb
commit
3824af0f12
38 files changed
+2398
-3552
No files matched your search
-1007
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { RotateCcw } from '@lucide/vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import { Button } from '@/components/ui'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, h } from 'vue'
|
||||
import { Trash2, Download, Eye, MoreHorizontal } from '@lucide/vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import Checkbox from '@/components/ui/checkbox/checkbox.vue'
|
||||
import Tag from '@/components/ui/tag/tag.vue'
|
||||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||||
import { Button, Checkbox, Tag, Dropdown } from '@/components/ui'
|
||||
import { getPreviewUrl } from '@/services/fileService'
|
||||
import {
|
||||
extFromName,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Tree from '@/components/ui/tree/tree.vue'
|
||||
import Spin from '@/components/ui/spin/spin.vue'
|
||||
import { Tree, Spin } from '@/components/ui'
|
||||
|
||||
const props = defineProps({
|
||||
treeData: { type: Array, default: () => [] },
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { Message } from '@/components/ui/message/message-manager'
|
||||
import Modal from '@/components/ui/modal/modal.vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import ButtonGroup from '@/components/ui/button-group/button-group.vue'
|
||||
import Checkbox from '@/components/ui/checkbox/checkbox.vue'
|
||||
import Spin from '@/components/ui/spin/spin.vue'
|
||||
import Tag from '@/components/ui/tag/tag.vue'
|
||||
import Tooltip from '@/components/ui/tooltip/tooltip.vue'
|
||||
import Pagination from '@/components/ui/pagination/pagination.vue'
|
||||
import {
|
||||
Message,
|
||||
Modal,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
Spin,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Pagination,
|
||||
} from '@/components/ui'
|
||||
import {
|
||||
Download,
|
||||
Trash2,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { ChevronLeft, ChevronRight, FileText } from '@lucide/vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import { Button } from '@/components/ui'
|
||||
import { getPreviewUrl } from '@/services/fileService'
|
||||
import { extColor, formatSize } from './fileVisual'
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import Splitter from '@/components/ui/splitter/splitter.vue'
|
||||
import SplitterPanel from '@/components/ui/splitter/panel.vue'
|
||||
import Spin from '@/components/ui/spin/spin.vue'
|
||||
import { Splitter, SplitterPanel, Spin } from '@/components/ui'
|
||||
import { loadDataApi } from '@/services/api'
|
||||
import FileCategoryPanel from './FileCategoryPanel.vue'
|
||||
import FileListPanel from './FileListPanel.vue'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import Modal from '@/components/ui/modal/modal.vue'
|
||||
import { Modal } from '@/components/ui'
|
||||
import { useFileModal } from './useFileModal'
|
||||
import FmsFile from './FmsFile.vue'
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ const props = defineProps({
|
||||
labelWidth: { type: [String, Number], default: undefined },
|
||||
/** 每行字段列数(>1 时启用网格布局,字段按顺序从左到右自动换行) */
|
||||
columns: { type: Number, default: 1 },
|
||||
/** 仅影响 FormItem 的 span 计算,不让 Form 自身变 grid(grid 由外部容器控制):
|
||||
* 用于「外部自定义栅格 + FormItem 跨列」的场景(如表单设计器预览) */
|
||||
spanColumns: { type: Number, default: 0 },
|
||||
/** 表单最大宽度(Number 按 px 处理;不传则不限制) */
|
||||
maxWidth: { type: [String, Number], default: undefined },
|
||||
/** 必填红星标记 */
|
||||
@@ -99,7 +102,7 @@ provide('fms-form', {
|
||||
layout: computed(() => props.layout),
|
||||
labelAlign: computed(() => props.labelAlign),
|
||||
labelWidth: computed(() => props.labelWidth),
|
||||
columns: computed(() => props.columns),
|
||||
columns: computed(() => props.spanColumns || props.columns),
|
||||
requiredMark: computed(() => props.requiredMark),
|
||||
validateTrigger: computed(() => props.validateTrigger),
|
||||
registerField,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* UI 组件库统一出口
|
||||
* 用法:import { Button, Input, Modal, Message } from '@/components/ui'
|
||||
*/
|
||||
import Button from './button/button.vue'
|
||||
import ButtonGroup from './button-group/button-group.vue'
|
||||
import Checkbox from './checkbox/checkbox.vue'
|
||||
import DatePicker from './date/date-picker.vue'
|
||||
import RangePicker from './date/range-picker.vue'
|
||||
import TimeSelect from './date/time-select.vue'
|
||||
import Drawer from './drawer/drawer.vue'
|
||||
import Dropdown from './dropdown/dropdown.vue'
|
||||
import Form from './form/form.vue'
|
||||
import FormItem from './form/form-item.vue'
|
||||
import Input from './input/input.vue'
|
||||
import Modal from './modal/modal.vue'
|
||||
import Pagination from './pagination/pagination.vue'
|
||||
import Popover from './popover/popover.vue'
|
||||
import Radio from './radio/radio.vue'
|
||||
import RadioGroup from './radio/radio-group.vue'
|
||||
import Scrollbar from './scrollbar/scrollbar.vue'
|
||||
import Select from './select/select.vue'
|
||||
import Spin from './spin/spin.vue'
|
||||
import Splitter from './splitter/splitter.vue'
|
||||
import SplitterPanel from './splitter/panel.vue'
|
||||
import Switch from './switch/switch.vue'
|
||||
import Tabs from './tabs/tabs.vue'
|
||||
import TabPane from './tabs/tab-pane.vue'
|
||||
import Tag from './tag/tag.vue'
|
||||
import Tooltip from './tooltip/tooltip.vue'
|
||||
import Tree from './tree/tree.vue'
|
||||
|
||||
import { Message } from './message/message-manager'
|
||||
|
||||
export {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
RangePicker,
|
||||
TimeSelect,
|
||||
Drawer,
|
||||
Dropdown,
|
||||
Form,
|
||||
FormItem,
|
||||
Input,
|
||||
Message,
|
||||
Modal,
|
||||
Pagination,
|
||||
Popover,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Scrollbar,
|
||||
Select,
|
||||
Spin,
|
||||
Splitter,
|
||||
SplitterPanel,
|
||||
Switch,
|
||||
Tabs,
|
||||
TabPane,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Tree,
|
||||
}
|
||||
@@ -71,9 +71,12 @@ const isOpen = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const panelWidth = computed(() =>
|
||||
typeof props.width === 'number' ? `${props.width}px` : props.width,
|
||||
)
|
||||
// number 按 px,纯数字字符串补 px,其余(如 480px / 60%)按 CSS 原样
|
||||
const panelWidth = computed(() => {
|
||||
if (typeof props.width === 'number') return `${props.width}px`
|
||||
const text = String(props.width).trim()
|
||||
return /^\d+(\.\d+)?$/.test(text) ? `${text}px` : text
|
||||
})
|
||||
|
||||
// 面板尺寸:缩放后使用实际宽高(px),否则用 width prop
|
||||
const panelStyle = computed(() => {
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
import { computed, defineComponent, h, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import Splitter from '@/components/ui/splitter/splitter.vue'
|
||||
import SplitterPanel from '@/components/ui/splitter/panel.vue'
|
||||
import { Splitter, SplitterPanel } from '@/components/ui'
|
||||
import AppSidebar from './components/AppSidebar.vue'
|
||||
import AppTopbar from './components/AppTopbar.vue'
|
||||
import DetailPage from '@/views/detail/DetailPage.vue'
|
||||
@@ -317,10 +316,8 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto hidden;
|
||||
/* 只保留上下 padding(8px),左右为 0:
|
||||
* 页面内容紧贴灰画布左右边缘(与主区面板的左右 8px 间距配合),
|
||||
* 让页面白卡尽可能占满横向空间 */
|
||||
padding: 8px 0;
|
||||
/* 四边统一 4px padding */
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
/* 页面过渡外层包裹:稳定的 DOM 根,供 transition 的 out-in 模式直接管理,
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useAppStore } from '@/stores/app'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
import { getIcon } from '@/utils/lucideIcons'
|
||||
import { buildTree, sortTree } from '@/utils/tree'
|
||||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||||
import { Dropdown } from '@/components/ui'
|
||||
import NavMain from './NavMain.vue'
|
||||
|
||||
const appStore = useAppStore()
|
||||
@@ -340,7 +340,7 @@ function toNavItem(node) {
|
||||
.fms-nav-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow: hidden auto;
|
||||
padding: 0 8px 8px;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { ChevronLeft, ChevronRight, X } from '@lucide/vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||||
import { Button, Dropdown } from '@/components/ui'
|
||||
import { cacheKeyOf } from '@/utils/cacheKey'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -18,10 +18,8 @@ import {
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Message } from '@/components/ui/message/message-manager'
|
||||
import { Message, Dropdown, Button } from '@/components/ui'
|
||||
import { PRESETS } from '@/theme/presets'
|
||||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import AppTabs from './AppTabs.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Message } from '@/components/ui/message/message-manager'
|
||||
import { Message } from '@/components/ui'
|
||||
|
||||
const AUTH_ERROR_CODE = 401
|
||||
const SESSION_REPLACED_CODE = 40101
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
|
||||
/* 表单(form):label 宽度 / 表单项间距 / 辅助提示字号(不随主题变化) */
|
||||
--fms-form-label-width: 88px;
|
||||
--fms-form-item-margin-bottom: 18px;
|
||||
--fms-form-item-margin-bottom: 6px;
|
||||
--fms-form-help-font-size: 12px;
|
||||
|
||||
/* 阴影(对齐 shadcn-vue / Tailwind v4 shadow-xs:0 1px 2px 0 rgb(0 0 0 / 0.05)) */
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
/**
|
||||
* 按 displayFormat 将原始值格式化为展示字符串,供数据层预格式化使用。
|
||||
* 展示值直接写入行数据,表格走原生文本渲染,无 customCell 运行时开销。
|
||||
* @param {*} value - 原始值
|
||||
* @param {string|Function} format - 'money' | 'number' | 'percent' | 'date' | 'datetime' | 'time' | 自定义函数
|
||||
* @returns {string} 格式化后的文本
|
||||
*/
|
||||
export function formatCellValue(value, format) {
|
||||
if (value === null || value === undefined || value === '') return ''
|
||||
if (typeof format === 'function') return String(format(value))
|
||||
|
||||
switch (format) {
|
||||
case 'money': {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return String(value)
|
||||
return (
|
||||
'¥' +
|
||||
num.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
)
|
||||
}
|
||||
case 'number': {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return String(value)
|
||||
return num.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
}
|
||||
case 'percent': {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return String(value)
|
||||
return (num * 100).toFixed(1) + '%'
|
||||
}
|
||||
case 'date':
|
||||
return dayjs(value).format('YYYY-MM-DD')
|
||||
case 'datetime':
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
case 'time':
|
||||
return dayjs(value).format('HH:mm:ss')
|
||||
default:
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { LayoutDashboard } from '@lucide/vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
SlidersHorizontal,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from '@lucide/vue'
|
||||
import { Button, Dropdown, Input, Pagination, RangePicker, Select } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
|
||||
defineOptions({ name: 'Dashboard' })
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 跳转到详情页(不同 id = 不同路由缓存),用于验证「多参数独立缓存」
|
||||
function goDetail(id) {
|
||||
router.push(`/detail/${id}`)
|
||||
}
|
||||
|
||||
/* ---------- fms-table 大数据量 + 单行编辑 demo ---------- */
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'task', label: '任务' },
|
||||
@@ -57,9 +57,16 @@ function makeColumns() {
|
||||
editor: { type: 'input' },
|
||||
})
|
||||
}
|
||||
// 纯展示列(无 editor、无 customCell),走库原生文本渲染:
|
||||
// money 千分位 / datetime 日期时间 / date 日期 / percent 百分比
|
||||
columns.push(
|
||||
{ title: '金额', dataIndex: 'amount', width: 120 },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170 },
|
||||
{ title: '到期日', dataIndex: 'expireDate', width: 110 },
|
||||
{ title: '完成率', dataIndex: 'progress', width: 90 },
|
||||
)
|
||||
return columns
|
||||
}
|
||||
|
||||
function makeRows(count) {
|
||||
const rows = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -70,6 +77,10 @@ function makeRows(count) {
|
||||
status: STATUS_OPTIONS[i % STATUS_OPTIONS.length].value,
|
||||
enabled: i % 2,
|
||||
score: Math.round(Math.random() * 1000) / 10,
|
||||
amount: Math.round(Math.random() * 10000000) / 100,
|
||||
createdAt: new Date(Date.now() - (count - i) * 3600_000).toISOString(),
|
||||
expireDate: new Date(Date.now() + ((i % 365) + 1) * 86400_000).toISOString(),
|
||||
progress: Math.round(Math.random() * 100) / 100,
|
||||
}
|
||||
for (let j = 1; j <= EXTRA_FIELD_COUNT; j++) row[`f${j}`] = `${i + 1}-${j}`
|
||||
rows.push(row)
|
||||
@@ -77,68 +88,117 @@ function makeRows(count) {
|
||||
return rows
|
||||
}
|
||||
|
||||
const DEFAULT_ROW_COUNT = 5000
|
||||
// 行级特殊样式:根据业务字段返回行 class(经 $attrs 透传给 stk-table 的 rowClassName)。
|
||||
// 返回的 class 在下方 <style scoped> 中通过 :deep() 定义字体/背景颜色。
|
||||
function rowClassName(row) {
|
||||
if (row.type === 'alert') return 'fms-row-alert'
|
||||
if (row.status === 'pending') return 'fms-row-pending'
|
||||
if (row.status === 'disabled') return 'fms-row-disabled'
|
||||
return ''
|
||||
}
|
||||
|
||||
/* ---------- 表格数据:默认 1w 行(虚拟滚动) ---------- */
|
||||
const DEFAULT_ROW_COUNT = 10000
|
||||
const columns = ref(makeColumns())
|
||||
const rowCount = ref(DEFAULT_ROW_COUNT)
|
||||
const dataSource = ref(makeRows(DEFAULT_ROW_COUNT))
|
||||
const editCount = ref(0)
|
||||
|
||||
// 修改行数后重新生成全新数据(行对象全部重建,fms-table 编辑状态随之自然重置)
|
||||
function regenerate() {
|
||||
const count = Math.max(
|
||||
1,
|
||||
Math.min(100000, Math.floor(Number(rowCount.value)) || DEFAULT_ROW_COUNT),
|
||||
)
|
||||
rowCount.value = count
|
||||
dataSource.value = makeRows(count)
|
||||
editCount.value = 0
|
||||
/* ---------- 工具栏 UI(仅界面骨架,搜索/查询逻辑后续接入) ---------- */
|
||||
const keyword = ref('')
|
||||
const advancedOpen = ref(false)
|
||||
const advancedForm = ref({
|
||||
name: '',
|
||||
type: undefined,
|
||||
status: undefined,
|
||||
range: null,
|
||||
})
|
||||
|
||||
const moreItems = [
|
||||
{ key: 'export', label: '导出数据', icon: Download },
|
||||
{ key: 'import', label: '导入数据', icon: Upload },
|
||||
{ key: 'refresh', label: '刷新', icon: RefreshCw },
|
||||
{ key: 'divider-1', divider: true },
|
||||
{ key: 'batch-delete', label: '批量删除', icon: Trash2, danger: true },
|
||||
]
|
||||
|
||||
// TODO: 接入真实搜索 / 高级搜索 / 更多操作 / 新增逻辑
|
||||
function onMoreAction() {}
|
||||
function onAdvancedSearch() {}
|
||||
function onAdvancedReset() {
|
||||
advancedForm.value = { name: '', type: undefined, status: undefined, range: null }
|
||||
}
|
||||
|
||||
// fms-table 编辑提交时回传 (row, col, newValue),这里统计编辑次数
|
||||
function onCellChange() {
|
||||
editCount.value++
|
||||
}
|
||||
|
||||
const totalCells = computed(() => columns.value.length * dataSource.value.length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<header class="dashboard-header">
|
||||
<div class="dashboard-heading">
|
||||
<div class="dashboard-icon" aria-hidden="true">
|
||||
<LayoutDashboard />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="dashboard-title">工作台 · fms-table Demo</h1>
|
||||
<p class="dashboard-desc">
|
||||
20 列单行编辑示例:单击单元格进入编辑,Enter 提交,Esc 取消,方向键在单元格间移动。默认
|
||||
5000 行(虚拟滚动),可修改行数后「重新生成」。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-actions">
|
||||
<Button type="primary" @click="goDetail(1)">打开详情 #1</Button>
|
||||
<Button type="primary" @click="goDetail(2)">打开详情 #2</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="demo-card">
|
||||
<!-- 工具栏:搜索 + 高级搜索 | 更多操作 + 新增 -->
|
||||
<div class="demo-toolbar">
|
||||
<label class="toolbar-field">
|
||||
<span class="toolbar-label">行数</span>
|
||||
<Input v-model="rowCount" type="number" class="toolbar-input" />
|
||||
</label>
|
||||
<Button type="primary" @click="regenerate">重新生成</Button>
|
||||
<span class="toolbar-meta">
|
||||
{{ dataSource.length.toLocaleString() }} 行 × {{ columns.length }} 列(内置序号列不计入),
|
||||
共 {{ totalCells.toLocaleString() }} 个单元格 · 已编辑 {{ editCount }} 次
|
||||
</span>
|
||||
<div class="toolbar-left">
|
||||
<Input
|
||||
v-model="keyword"
|
||||
class="toolbar-search"
|
||||
placeholder="搜索名称 / ID"
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix><Search :size="14" aria-hidden="true" /></template>
|
||||
</Input>
|
||||
<Button
|
||||
type="outline"
|
||||
:class="{ 'toolbar-toggle-active': advancedOpen }"
|
||||
@click="advancedOpen = !advancedOpen"
|
||||
>
|
||||
<SlidersHorizontal :size="14" aria-hidden="true" />
|
||||
高级搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<Dropdown :items="moreItems" @click="onMoreAction">
|
||||
<template #trigger>
|
||||
<Button type="outline">
|
||||
更多操作
|
||||
<ChevronDown :size="14" aria-hidden="true" />
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
<Button type="primary">
|
||||
<Plus :size="14" aria-hidden="true" />
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 高级搜索面板:label 在上、控件等宽网格(适配多语言标签长度),仅 UI 骨架 -->
|
||||
<div v-show="advancedOpen" class="demo-advanced">
|
||||
<div class="advanced-field">
|
||||
<label class="advanced-label">名称 / ID</label>
|
||||
<Input v-model="advancedForm.name" placeholder="模糊搜索" allow-clear />
|
||||
</div>
|
||||
<div class="advanced-field">
|
||||
<label class="advanced-label">类型</label>
|
||||
<Select v-model="advancedForm.type" :options="TYPE_OPTIONS" clearable />
|
||||
</div>
|
||||
<div class="advanced-field">
|
||||
<label class="advanced-label">状态</label>
|
||||
<Select v-model="advancedForm.status" :options="STATUS_OPTIONS" clearable />
|
||||
</div>
|
||||
<div class="advanced-field">
|
||||
<label class="advanced-label">创建时间</label>
|
||||
<RangePicker v-model="advancedForm.range" />
|
||||
</div>
|
||||
<div class="advanced-actions">
|
||||
<Button type="primary" @click="onAdvancedSearch">查询</Button>
|
||||
<Button type="secondary" @click="onAdvancedReset">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="demo-table">
|
||||
<FmsTable :columns="columns" :data-source="dataSource" row-key="id" @change="onCellChange" />
|
||||
<FmsTable :columns="columns" :data-source="dataSource" row-key="id" />
|
||||
</div>
|
||||
|
||||
<!-- 底部分页:仅 UI 骨架,分页逻辑后续接入 -->
|
||||
<div class="demo-pagination">
|
||||
<Pagination :total="dataSource.length" show-total show-size-changer />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
@@ -155,56 +215,7 @@ const totalCells = computed(() => columns.value.length * dataSource.value.length
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.dashboard-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex: none;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, var(--fms-card));
|
||||
color: var(--fms-primary);
|
||||
|
||||
:deep(svg) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard-desc {
|
||||
margin: 2px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.dashboard-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ---- demo 卡片:工具栏 + 表格(灰底画布上的白卡,靠阴影分层,无边框线) ---- */
|
||||
/* ---- demo 卡片:工具栏 + 高级搜索 + 表格 + 分页(灰底画布上的白卡,靠阴影分层,无边框线) ---- */
|
||||
.demo-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -216,38 +227,121 @@ const totalCells = computed(() => columns.value.length * dataSource.value.length
|
||||
box-shadow: var(--fms-shadow);
|
||||
}
|
||||
|
||||
/* ---- 工具栏:左侧搜索 + 高级搜索,右侧更多操作 + 新增 ---- */
|
||||
.demo-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
.toolbar-field {
|
||||
display: inline-flex;
|
||||
.toolbar-left,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-label {
|
||||
font-size: 13px;
|
||||
color: var(--fms-text-secondary);
|
||||
.toolbar-search {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.toolbar-input {
|
||||
width: 120px;
|
||||
/* 高级搜索按钮:展开面板时高亮 */
|
||||
.toolbar-toggle-active {
|
||||
color: var(--fms-primary);
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, var(--fms-card));
|
||||
}
|
||||
|
||||
.toolbar-meta {
|
||||
margin-left: auto;
|
||||
/* ---- 高级搜索面板:label 在上、控件等宽网格。
|
||||
* 多语言下标签长度不一,label 置顶 + 控件铺满网格单元即可保证各列对齐;
|
||||
* 查询/重置独立一行、靠右。 ---- */
|
||||
.demo-advanced {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 12px 16px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
background: color-mix(in srgb, var(--fms-background) 30%, var(--fms-card));
|
||||
}
|
||||
|
||||
.advanced-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.advanced-label {
|
||||
font-size: 12px;
|
||||
color: var(--fms-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 控件统一铺满所在网格单元(Input / Select / RangePicker 根节点) */
|
||||
.advanced-field :deep(.input-wrapper),
|
||||
.advanced-field :deep(.select),
|
||||
.advanced-field :deep(.fms-range-picker) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.advanced-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.demo-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
/* 去掉表格外框:卡片本身靠阴影分层,表格直接铺满白卡(仅此处覆盖,
|
||||
不影响其他页面使用 FmsTable 时保留外框) */
|
||||
:deep(.fms-table) {
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* 行级特殊样式:rowClassName 返回的 class 落在 stk-table 的 tr 上,
|
||||
这里通过 :deep() 跨过组件边界设置字体颜色与背景色 */
|
||||
:deep(.fms-row-alert) {
|
||||
color: var(--fms-danger);
|
||||
|
||||
td {
|
||||
background: color-mix(in srgb, var(--fms-danger) 8%, var(--fms-card));
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.fms-row-pending) {
|
||||
color: var(--fms-warning);
|
||||
|
||||
td {
|
||||
background: color-mix(in srgb, var(--fms-warning) 8%, var(--fms-card));
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.fms-row-disabled) {
|
||||
color: var(--fms-disabled-text);
|
||||
|
||||
td {
|
||||
background: var(--fms-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 底部分页条:总数靠左,分页靠右 ---- */
|
||||
.demo-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@ defineOptions({ name: 'Demo' })
|
||||
import { Check, Moon, Sun } from '@lucide/vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { PRESETS } from '@/theme/presets'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import { Button } from '@/components/ui'
|
||||
import ButtonDemo from '@/components/ui/demo/button.vue'
|
||||
import ButtonGroupDemo from '@/components/ui/demo/button-group.vue'
|
||||
import TabsDemo from '@/components/ui/demo/tabs.vue'
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { RotateCcw, FileUp, ExternalLink, Inbox } from '@lucide/vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
import Checkbox from '@/components/ui/checkbox/checkbox.vue'
|
||||
import { Button, Input, Checkbox } from '@/components/ui'
|
||||
import FmsFile from '@/components/fms-file/FmsFile.vue'
|
||||
import FmsFileModal from '@/components/fms-file/FmsFileModal.vue'
|
||||
import { useFileModal } from '@/components/fms-file/useFileModal'
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Building2, GalleryVerticalEnd, KeyRound, User } from '@lucide/vue'
|
||||
import Form from '@/components/ui/form/form.vue'
|
||||
import FormItem from '@/components/ui/form/form-item.vue'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import Message from '@/components/ui/message/message-manager.js'
|
||||
import { Form, FormItem, Input, Button, Message } from '@/components/ui'
|
||||
import { loginApi } from '@/services/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
|
||||
+1
-3
@@ -5,9 +5,7 @@
|
||||
* 语言列表暂用前端常量;后续若引入语言类型表(s_i18n_type)再改为接口加载。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import Message from '@/components/ui/message/message-manager'
|
||||
import Modal from '@/components/ui/modal/modal.vue'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
import { Message, Modal, Input } from '@/components/ui'
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import { DEFAULT_LANGUAGES } from '../common'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* 图标名首字母大写,如 "Settings"、"Users"。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import Select from '@/components/ui/select/select.vue'
|
||||
import { Select } from '@/components/ui'
|
||||
import { getIcon } from '@/utils/lucideIcons'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -5,11 +5,7 @@
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Languages } from '@lucide/vue'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
import Select from '@/components/ui/select/select.vue'
|
||||
import Switch from '@/components/ui/switch/switch.vue'
|
||||
import Form from '@/components/ui/form/form.vue'
|
||||
import FormItem from '@/components/ui/form/form-item.vue'
|
||||
import { Input, Select, Switch, Form, FormItem } from '@/components/ui'
|
||||
import { buildModuleI18nKey, validateModuleCode } from '@/utils/i18n'
|
||||
import IconSelect from './IconSelect.vue'
|
||||
import ModulePowersPanel from './ModulePowersPanel.vue'
|
||||
|
||||
+1
-5
@@ -4,11 +4,7 @@
|
||||
* 保存时 b_current_period / b_current_value 由服务端维护,前端不提交。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
import Select from '@/components/ui/select/select.vue'
|
||||
import Switch from '@/components/ui/switch/switch.vue'
|
||||
import Form from '@/components/ui/form/form.vue'
|
||||
import FormItem from '@/components/ui/form/form-item.vue'
|
||||
import { Input, Select, Switch, Form, FormItem } from '@/components/ui'
|
||||
import { createTempId } from '@/utils/tempId'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Languages } from '@lucide/vue'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
import Select from '@/components/ui/select/select.vue'
|
||||
import Switch from '@/components/ui/switch/switch.vue'
|
||||
import Form from '@/components/ui/form/form.vue'
|
||||
import FormItem from '@/components/ui/form/form-item.vue'
|
||||
import { Input, Select, Switch, Form, FormItem } from '@/components/ui'
|
||||
import { buildModuleI18nKey, validateModuleCode } from '@/utils/i18n'
|
||||
import I18nQuickSetModal from './I18nQuickSetModal.vue'
|
||||
|
||||
|
||||
+497
-159
@@ -9,8 +9,6 @@
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||
import {
|
||||
Trash2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Asterisk,
|
||||
Eye,
|
||||
EyeOff,
|
||||
@@ -22,12 +20,20 @@ import {
|
||||
Check,
|
||||
X,
|
||||
Plus,
|
||||
Pencil,
|
||||
Settings2,
|
||||
} from '@lucide/vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import Message from '@/components/ui/message/message-manager'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import Select from '@/components/ui/select/select.vue'
|
||||
import {
|
||||
Message,
|
||||
Button,
|
||||
Select,
|
||||
Input,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Form,
|
||||
FormItem,
|
||||
Modal,
|
||||
} from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { createTempId } from '@/utils/tempId'
|
||||
|
||||
@@ -70,6 +76,67 @@ const dragSourceId = ref(null)
|
||||
const dragLine = ref(null)
|
||||
// 拖拽悬停中的分区 key(用于分组放置逻辑)
|
||||
const dragOverSection = ref(null)
|
||||
// 分组拖拽状态:同字段拖拽的 dragPayload,用于拖拽时其他分区变淡
|
||||
const sectionDragging = ref(false)
|
||||
|
||||
/* ================= 分组宽度(外层 48 宫格,前端暂存不持久化) =================
|
||||
* 分组像字段一样按占格数流式排布:默认 48(独占一行),调小后自动并排换行;
|
||||
* 分组内部字段仍是独立 24 宫格 */
|
||||
const GROUP_GRID = 48
|
||||
// 分组设置弹窗的常用档位:分组宽度(48 宫格)与新建字段宽度(24 宫格)
|
||||
const GROUP_STEP_COLS = [48, 24, 16, 12]
|
||||
const GROUP_STEP_FIELDS = [24, 12, 8, 6]
|
||||
const sectionCols = reactive({}) // key(g-xxx/ungrouped) → 占格数
|
||||
|
||||
function colsOf(key) {
|
||||
return sectionCols[key] ?? GROUP_GRID
|
||||
}
|
||||
|
||||
function setSectionCols(key, val) {
|
||||
const n = Math.round(Number(val))
|
||||
sectionCols[key] =
|
||||
Number.isFinite(n) && n > 0 ? Math.min(Math.max(n, 1), GROUP_GRID) : GROUP_GRID
|
||||
}
|
||||
|
||||
/* 分组设置弹窗:正在设置的分区 + 草稿值 */
|
||||
const sectionSettingsKey = ref(null)
|
||||
const sectionSettingsOpen = computed({
|
||||
get: () => sectionSettingsKey.value !== null,
|
||||
set: (v) => { if (!v) sectionSettingsKey.value = null },
|
||||
})
|
||||
const sectionSettings = reactive({ cols: GROUP_GRID, fieldCols: 24 })
|
||||
|
||||
/* 表单预览弹窗(主表):model 仅为 Form 组件提供数据上下文,预览不绑定真实值 */
|
||||
const previewOpen = ref(false)
|
||||
const previewModel = reactive({})
|
||||
|
||||
function openSectionSettings(section) {
|
||||
sectionSettingsKey.value = section.key
|
||||
sectionSettings.cols = section.cols
|
||||
sectionSettings.fieldCols = section.fieldCols
|
||||
}
|
||||
|
||||
function saveSectionSettings() {
|
||||
const key = sectionSettingsKey.value
|
||||
if (key) {
|
||||
setSectionCols(key, sectionSettings.cols)
|
||||
setSectionFieldCols(key, sectionSettings.fieldCols)
|
||||
}
|
||||
sectionSettingsKey.value = null
|
||||
}
|
||||
|
||||
/* 分组内新建字段默认宽度(24 宫格,前端暂存):key → 占格数,默认 24 独占一行 */
|
||||
const sectionFieldCols = reactive({})
|
||||
|
||||
function fieldColsOf(key) {
|
||||
return sectionFieldCols[key] ?? 24
|
||||
}
|
||||
|
||||
function setSectionFieldCols(key, val) {
|
||||
const n = Math.round(Number(val))
|
||||
sectionFieldCols[key] =
|
||||
Number.isFinite(n) && n > 0 ? Math.min(Math.max(n, 1), 24) : 24
|
||||
}
|
||||
|
||||
/* ================= 设计工作区:属性面板折叠 ================= */
|
||||
const inspectorCollapsed = ref(false) // 属性面板折叠
|
||||
@@ -447,30 +514,53 @@ function rebuildSectionCache() {
|
||||
|
||||
watch([localEdit, () => props.groups], rebuildSectionCache, { deep: true, immediate: true })
|
||||
|
||||
// 画布分区:按分组(b_xh 排序)+ 未分组兜底
|
||||
/* 分区持久顺序(含 ungrouped):用户拖拽分组/未分组卡片后保持,canvasSections 按其排列,
|
||||
* 否则每次重建未分组都会弹回末尾 */
|
||||
const sectionOrderKeys = ref([])
|
||||
|
||||
// 画布分区:按分组(b_xh 排序)+ 未分组兜底。
|
||||
// 未分组也是一种分组:可拖拽排序(顺序持久化)、标题不可改、header 有视觉区分。
|
||||
// 每个分区有 cols(外层 48 宫格占格数),默认 48 独占一行,调小后流式排布。
|
||||
const canvasSections = computed(() => {
|
||||
const sections = []
|
||||
const built = []
|
||||
const sortedGroups = props.groups.toSorted((a, b) => (a.b_xh || 0) - (b.b_xh || 0))
|
||||
for (const group of sortedGroups) {
|
||||
const gid = String(group.b_id)
|
||||
sections.push({
|
||||
built.push({
|
||||
key: `g-${gid}`,
|
||||
// 标题留空则不渲染标题文字(分组仍作为布局分区)
|
||||
title: (group.b_title || '').trim(),
|
||||
group,
|
||||
disabled: Number(group.b_canuse) !== 1,
|
||||
rows: sectionRowCache[gid] || [],
|
||||
cols: colsOf(`g-${gid}`),
|
||||
fieldCols: fieldColsOf(`g-${gid}`),
|
||||
})
|
||||
}
|
||||
// 未分组也是一种分组:有字段时作为分区展示(无字段时不占位)
|
||||
if ((sectionRowCache[''] || []).length) {
|
||||
sections.push({
|
||||
built.push({
|
||||
key: 'ungrouped',
|
||||
title: '未分组',
|
||||
group: null,
|
||||
rows: sectionRowCache[''],
|
||||
cols: colsOf('ungrouped'),
|
||||
fieldCols: fieldColsOf('ungrouped'),
|
||||
})
|
||||
}
|
||||
return sections
|
||||
// 未初始化持久顺序:直接用默认顺序(分组 b_xh 排序 + 未分组垫底)
|
||||
if (!sectionOrderKeys.value.length) return built
|
||||
// 按持久顺序重排;不在顺序里的新 key 追加末尾(新增分组自动落底)
|
||||
const byKey = new Map(built.map((s) => [s.key, s]))
|
||||
const ordered = []
|
||||
for (const key of sectionOrderKeys.value) {
|
||||
if (byKey.has(key)) {
|
||||
ordered.push(byKey.get(key))
|
||||
byKey.delete(key)
|
||||
}
|
||||
}
|
||||
for (const s of byKey.values()) ordered.push(s)
|
||||
return ordered
|
||||
})
|
||||
|
||||
function groupIdOfSection(section) {
|
||||
@@ -484,12 +574,19 @@ watch(
|
||||
canvasSections,
|
||||
(sections) => {
|
||||
sectionOrder.value = sections.map((s) => s)
|
||||
// 首次:初始化持久顺序(默认 = 当前分区顺序)
|
||||
if (!sectionOrderKeys.value.length) {
|
||||
sectionOrderKeys.value = sections.map((s) => s.key)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 分组拖拽结束:按新顺序重排 groups 的 b_xh 并提交(未分组不参与,自动回到末尾)
|
||||
// 分组拖拽结束:清除拖拽状态,按新顺序重排 groups 的 b_xh 并提交(未分组是分区之一,
|
||||
// 持久顺序里记录它的位置,其余分组按其相对位置重排 b_xh)
|
||||
function onGroupDragEnd() {
|
||||
sectionDragging.value = false
|
||||
sectionOrderKeys.value = sectionOrder.value.map((s) => s.key)
|
||||
const groupsMap = new Map((props.groups || []).map((g) => [String(g.b_id), g]))
|
||||
const ordered = []
|
||||
for (const section of sectionOrder.value) {
|
||||
@@ -507,6 +604,7 @@ function onGroupDragEnd() {
|
||||
/* ================= 分组标题内联编辑(点击分组名变输入框 + 确认/取消) ================= */
|
||||
const editingGroupKey = ref(null) // 正在编辑的分区 key(g-xxx / ungrouped)
|
||||
const groupTitleDraft = ref('')
|
||||
const sectionEditInput = ref(null) // 编辑态输入框,用于自动聚焦/全选
|
||||
|
||||
// 提交正在编辑的标题(若编辑目标已被切换/删除,直接丢弃草稿,避免基于旧分组提交)
|
||||
function flushEditingGroupTitle() {
|
||||
@@ -523,6 +621,13 @@ function startEditGroupTitle(section) {
|
||||
flushEditingGroupTitle()
|
||||
editingGroupKey.value = section.key
|
||||
groupTitleDraft.value = section.group.b_title || ''
|
||||
// 下一帧聚焦输入框并全选文字,方便直接输入替换
|
||||
nextTick(() => {
|
||||
const el = sectionEditInput.value
|
||||
if (!el) return
|
||||
el.focus()
|
||||
el.select()
|
||||
})
|
||||
}
|
||||
|
||||
function cancelEditGroupTitle() {
|
||||
@@ -628,6 +733,7 @@ function selectRow(row) {
|
||||
|
||||
// 字段库 → 画布:已加入则定位选中,未加入则按目标位置插入。
|
||||
function createEditRow(field, groupId) {
|
||||
const sectionKey = groupId == null ? 'ungrouped' : `g-${String(groupId)}`
|
||||
return {
|
||||
b_id: createTempId(),
|
||||
b_module_id: props.moduleId,
|
||||
@@ -637,7 +743,8 @@ function createEditRow(field, groupId) {
|
||||
b_required: 0,
|
||||
b_readonly: 0,
|
||||
b_disabled: 0,
|
||||
b_colspan: 24,
|
||||
// 新字段宽度取该分组设置的默认宽度(默认 24 独占一行)
|
||||
b_colspan: fieldColsOf(sectionKey),
|
||||
b_height: field.b_type === 'textarea' ? 120 : null,
|
||||
b_placeholder: null,
|
||||
b_xh: 0,
|
||||
@@ -1492,6 +1599,9 @@ onBeforeUnmount(() => {
|
||||
<Button type="outline" :disabled="loading" @click="addGroup">
|
||||
<Plus :size="14" /> 添加分组
|
||||
</Button>
|
||||
<Button type="ghost" :disabled="loading" @click="previewOpen = true">
|
||||
<Eye :size="14" /> 预览
|
||||
</Button>
|
||||
<div class="mm-edit__help">
|
||||
<span class="mm-edit__help-icon" aria-hidden="true">?</span>
|
||||
<div class="mm-edit__help-pop">
|
||||
@@ -1508,7 +1618,10 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<!-- ================= 表单(主表)设计:画布 ================= -->
|
||||
<div class="mm-fdesign__canvas-wrap" :class="{ 'is-drag-active': dragPayload }">
|
||||
<div
|
||||
class="mm-fdesign__canvas-wrap"
|
||||
:class="{ 'is-drag-active': dragPayload, 'is-section-drag-active': sectionDragging }"
|
||||
>
|
||||
<div class="mm-fdesign__canvas">
|
||||
<div class="mm-fdesign__sheet" @click="selectRow(null)">
|
||||
<template v-if="canvasSections.length">
|
||||
@@ -1516,11 +1629,13 @@ onBeforeUnmount(() => {
|
||||
v-model="sectionOrder"
|
||||
handle=".mm-fdesign__section-grip"
|
||||
item-key="key"
|
||||
class="mm-fdesign__sections"
|
||||
:animation="150"
|
||||
ghost-class="mm-fdesign__section-ghost"
|
||||
:fallback-on-body="true"
|
||||
:force-fallback="true"
|
||||
fallback-class="mm-fdesign__drag-fallback"
|
||||
@start="sectionDragging = true"
|
||||
@end="onGroupDragEnd"
|
||||
>
|
||||
<template #item="{ element: section }">
|
||||
@@ -1531,10 +1646,11 @@ onBeforeUnmount(() => {
|
||||
'is-horizontal': section.group?.b_layout_direction === 'horizontal',
|
||||
'is-ungrouped': section.key === 'ungrouped',
|
||||
}"
|
||||
:style="{ gridColumn: `span ${section.cols}` }"
|
||||
>
|
||||
<div class="mm-fdesign__section-title">
|
||||
<button
|
||||
v-if="section.group && editingGroupKey !== section.key"
|
||||
v-if="(section.group || section.key === 'ungrouped') && editingGroupKey !== section.key"
|
||||
type="button"
|
||||
class="mm-fdesign__section-grip"
|
||||
title="拖动调整分组顺序"
|
||||
@@ -1544,6 +1660,7 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
<template v-if="editingGroupKey === section.key">
|
||||
<input
|
||||
ref="sectionEditInput"
|
||||
v-model="groupTitleDraft"
|
||||
class="mm-fdesign__section-edit"
|
||||
maxlength="100"
|
||||
@@ -1589,16 +1706,14 @@ onBeforeUnmount(() => {
|
||||
@click.stop="startEditGroupTitle(section)"
|
||||
>空</span>
|
||||
<span class="mm-edit__section-meta">
|
||||
<span class="mm-edit__section-count">{{ section.rows.length }} 字段</span>
|
||||
<button
|
||||
v-if="section.group"
|
||||
type="button"
|
||||
class="mm-fdesign__section-btn"
|
||||
title="修改分组标题"
|
||||
aria-label="修改分组标题"
|
||||
@click.stop="startEditGroupTitle(section)"
|
||||
title="分组设置"
|
||||
aria-label="分组设置"
|
||||
@click.stop="openSectionSettings(section)"
|
||||
>
|
||||
<Pencil :size="13" />
|
||||
<Settings2 :size="13" />
|
||||
</button>
|
||||
<button
|
||||
v-if="section.group"
|
||||
@@ -1625,7 +1740,7 @@ onBeforeUnmount(() => {
|
||||
:fallback-on-body="true"
|
||||
:force-fallback="true"
|
||||
fallback-class="mm-fdesign__drag-fallback"
|
||||
:filter="'.mm-fdesign__field-toolbar, button'"
|
||||
:filter="'button'"
|
||||
:prevent-on-filter="true"
|
||||
:move="onFieldMove"
|
||||
@start="onFieldDragStart"
|
||||
@@ -1701,9 +1816,15 @@ onBeforeUnmount(() => {
|
||||
<EyeOff v-else :size="12" />
|
||||
</button>
|
||||
</span>
|
||||
<i v-if="Number(row.b_visible) !== 1" class="mm-fdesign__invisible-mark"
|
||||
>隐藏</i
|
||||
<button
|
||||
type="button"
|
||||
class="mm-fdesign__tool-btn is-danger"
|
||||
title="删除字段"
|
||||
aria-label="删除字段"
|
||||
@click.stop="removeRow(row)"
|
||||
>
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mm-fdesign__field-control">
|
||||
<input
|
||||
@@ -1761,41 +1882,6 @@ onBeforeUnmount(() => {
|
||||
:placeholder="placeholderOf(row)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectedRowKey === String(row.b_id)"
|
||||
class="mm-fdesign__field-toolbar"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="mm-fdesign__tool-btn"
|
||||
data-action="move-up"
|
||||
title="上移"
|
||||
aria-label="上移"
|
||||
@click.stop="moveSelectedRow(-1)"
|
||||
>
|
||||
<ArrowUp :size="13" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mm-fdesign__tool-btn"
|
||||
data-action="move-down"
|
||||
title="下移"
|
||||
aria-label="下移"
|
||||
@click.stop="moveSelectedRow(1)"
|
||||
>
|
||||
<ArrowDown :size="13" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mm-fdesign__tool-btn is-danger"
|
||||
data-action="delete"
|
||||
title="删除字段"
|
||||
aria-label="删除字段"
|
||||
@click.stop="removeRow(row)"
|
||||
>
|
||||
<Trash2 :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
@@ -2033,6 +2119,157 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================= 分组设置弹窗 ================= -->
|
||||
<Modal
|
||||
v-model:open="sectionSettingsOpen"
|
||||
title="分组设置"
|
||||
width="420"
|
||||
:resizable="false"
|
||||
:fullscreen="false"
|
||||
@ok="saveSectionSettings"
|
||||
>
|
||||
<div class="mm-fdesign__section-settings">
|
||||
<div class="mm-fdesign__section-settings-row">
|
||||
<label class="mm-fdesign__section-settings-label">分组宽度</label>
|
||||
<div class="mm-fdesign__section-settings-control">
|
||||
<div class="mm-fdesign__section-settings-steps">
|
||||
<button
|
||||
v-for="v in GROUP_STEP_COLS"
|
||||
:key="v"
|
||||
type="button"
|
||||
class="mm-fdesign__section-settings-step"
|
||||
:class="{ 'is-active': sectionSettings.cols === v }"
|
||||
@click="sectionSettings.cols = v"
|
||||
>{{ v }}</button>
|
||||
</div>
|
||||
<Input
|
||||
v-model="sectionSettings.cols"
|
||||
type="number"
|
||||
min="1"
|
||||
max="48"
|
||||
step="1"
|
||||
class="mm-fdesign__section-settings-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mm-fdesign__section-settings-row">
|
||||
<label class="mm-fdesign__section-settings-label">新建字段默认宽度</label>
|
||||
<div class="mm-fdesign__section-settings-control">
|
||||
<div class="mm-fdesign__section-settings-steps">
|
||||
<button
|
||||
v-for="v in GROUP_STEP_FIELDS"
|
||||
:key="v"
|
||||
type="button"
|
||||
class="mm-fdesign__section-settings-step"
|
||||
:class="{ 'is-active': sectionSettings.fieldCols === v }"
|
||||
@click="sectionSettings.fieldCols = v"
|
||||
>{{ v }}</button>
|
||||
</div>
|
||||
<Input
|
||||
v-model="sectionSettings.fieldCols"
|
||||
type="number"
|
||||
min="1"
|
||||
max="24"
|
||||
step="1"
|
||||
class="mm-fdesign__section-settings-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- ================= 表单预览 ================= -->
|
||||
<Modal
|
||||
v-model:open="previewOpen"
|
||||
title="表单预览"
|
||||
width="760"
|
||||
:resizable="false"
|
||||
:fullscreen="false"
|
||||
:footer="false"
|
||||
>
|
||||
<Form
|
||||
:model="previewModel"
|
||||
layout="vertical"
|
||||
:span-columns="24"
|
||||
class="mm-fdesign__preview-form"
|
||||
>
|
||||
<div class="mm-fdesign__preview">
|
||||
<template v-if="canvasSections.length">
|
||||
<div
|
||||
v-for="section in canvasSections"
|
||||
:key="section.key"
|
||||
class="mm-fdesign__preview-section"
|
||||
:style="{ gridColumn: `span ${section.cols}` }"
|
||||
>
|
||||
<div
|
||||
v-if="section.title && section.group"
|
||||
class="mm-fdesign__preview-title"
|
||||
>{{ section.title }}</div>
|
||||
<div class="mm-fdesign__preview-grid">
|
||||
<FormItem
|
||||
v-for="row in section.rows"
|
||||
:key="row.b_id"
|
||||
:name="String(row.b_field_id)"
|
||||
:label="fieldName(row)"
|
||||
:required="Number(row.b_required) === 1"
|
||||
:span="spanOf(row)"
|
||||
>
|
||||
<Input
|
||||
v-if="fieldType(row) === 'input'"
|
||||
:placeholder="placeholderOf(row)"
|
||||
:disabled="Number(row.b_disabled) === 1"
|
||||
:readonly="Number(row.b_readonly) === 1"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="fieldType(row) === 'textarea'"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
:placeholder="placeholderOf(row)"
|
||||
:disabled="Number(row.b_disabled) === 1"
|
||||
:readonly="Number(row.b_readonly) === 1"
|
||||
/>
|
||||
<Select
|
||||
v-else-if="fieldType(row) === 'select'"
|
||||
:placeholder="placeholderOf(row)"
|
||||
:options="[]"
|
||||
:disabled="Number(row.b_disabled) === 1"
|
||||
/>
|
||||
<Checkbox
|
||||
v-else-if="fieldType(row) === 'checkbox'"
|
||||
:disabled="Number(row.b_disabled) === 1"
|
||||
>{{ fieldOf(row)?.b_default_value || '选项' }}</Checkbox>
|
||||
<DatePicker
|
||||
v-else-if="fieldType(row) === 'date'"
|
||||
:disabled="Number(row.b_disabled) === 1"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="fieldType(row) === 'datetime'"
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
:disabled="Number(row.b_disabled) === 1"
|
||||
/>
|
||||
<Input
|
||||
v-else-if="fieldType(row) === 'number' || fieldType(row) === 'money'"
|
||||
type="number"
|
||||
:placeholder="placeholderOf(row)"
|
||||
:disabled="Number(row.b_disabled) === 1"
|
||||
:readonly="Number(row.b_readonly) === 1"
|
||||
/>
|
||||
<Input
|
||||
v-else
|
||||
type="text"
|
||||
:placeholder="placeholderOf(row)"
|
||||
:disabled="Number(row.b_disabled) === 1"
|
||||
:readonly="Number(row.b_readonly) === 1"
|
||||
/>
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="mm-fdesign__preview-empty">暂无字段</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2500,20 +2737,27 @@ onBeforeUnmount(() => {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 未分组伪分区:虚线框 + 白底(与其他分组一致,不做特殊处理) */
|
||||
/* 未分组也是一种分组:实线边框,与正式分组一致;
|
||||
* header 用极淡主色 tint 区分(不灰、不虚线),配合「暂存」角标表明身份 */
|
||||
&.is-ungrouped {
|
||||
border-style: dashed;
|
||||
background: var(--fms-card);
|
||||
}
|
||||
|
||||
&.is-ungrouped .mm-fdesign__section-title {
|
||||
background: color-mix(in srgb, var(--fms-primary) 4%, var(--fms-card));
|
||||
}
|
||||
}
|
||||
|
||||
/* 分组标题行(主表表单设计):白底 header 条,与卡片圆角裁剪 */
|
||||
/* 分组标题行(主表表单设计):白底 header 条,与卡片圆角裁剪。
|
||||
* 上角圆角跟随卡片(8px):否则有色 header(如未分组的 tint)会顶着
|
||||
* 直角的 90°,在卡片圆角内侧露出白底三角区,看起来像边框被截断 */
|
||||
.mm-fdesign__section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
border-radius: 8px 8px 0 0;
|
||||
color: var(--fms-text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
@@ -2538,10 +2782,11 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 分组名编辑输入框 + 确认/取消 */
|
||||
/* 分组名编辑输入框 + 确认/取消:宽度自适应(填满标题栏剩余空间),
|
||||
* 短标题(如"基础")不会撑出大片空白,长标题自动延伸 */
|
||||
.mm-fdesign__section-edit {
|
||||
flex: 0 1 200px;
|
||||
min-width: 120px;
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--fms-primary);
|
||||
@@ -2654,11 +2899,6 @@ onBeforeUnmount(() => {
|
||||
box-shadow: inset 0 -2px 0 0 var(--fms-primary) !important;
|
||||
}
|
||||
|
||||
/* 拖拽中:隐藏卡片上的操作按钮(上移/下移/删除),避免干扰拖拽焦点 */
|
||||
.mm-fdesign__canvas-wrap.is-drag-active .mm-fdesign__field-toolbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---- 画布字段:所见即所得的真实控件(默认无背景无边框,直接排在白纸上;
|
||||
* hover 淡主色边框,选中虚线框;内部控件(input/checkbox 等)自带边框样式可区分) ---- */
|
||||
.mm-fdesign__field {
|
||||
@@ -2816,47 +3056,32 @@ textarea.mm-fdesign__ctrl {
|
||||
pointer-events: none; /* 纯预览,点击穿透到卡片 */
|
||||
}
|
||||
|
||||
/* 选中悬浮工具栏 */
|
||||
.mm-fdesign__field-toolbar {
|
||||
position: absolute;
|
||||
top: -11px;
|
||||
right: 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
padding: 2px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--fms-border);
|
||||
background: var(--fms-card);
|
||||
box-shadow: 0 1px 4px rgba(39, 54, 70, 0.12);
|
||||
}
|
||||
|
||||
/* 字段删除按钮:与必填/显示图标同款行内小图标,推至最右 */
|
||||
.mm-fdesign__tool-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-left: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--fms-text-secondary);
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, var(--fms-primary) 5%, transparent);
|
||||
color: var(--fms-text-disabled, #9ca3af);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--fms-secondary);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
/* 配对按钮激活(已选中第一个字段) */
|
||||
&.is-active {
|
||||
background: color-mix(in srgb, var(--fms-primary) 16%, transparent);
|
||||
color: var(--fms-primary);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
&.is-danger:hover {
|
||||
color: #d95757;
|
||||
background: color-mix(in srgb, #d95757 12%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2866,26 +3091,6 @@ textarea.mm-fdesign__ctrl {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* 隐藏字段角标:默认隐藏,hover / 选中时浮现(隐藏卡片本身已整体降透明度) */
|
||||
.mm-fdesign__invisible-mark {
|
||||
margin-left: auto;
|
||||
padding: 0 5px;
|
||||
border-radius: 4px;
|
||||
background: var(--fms-secondary);
|
||||
color: color-mix(in srgb, var(--fms-text-secondary) 65%, var(--fms-text));
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.mm-fdesign__field:hover .mm-fdesign__invisible-mark,
|
||||
.mm-fdesign__field.is-selected .mm-fdesign__invisible-mark,
|
||||
.mm-fdesign__field.is-invisible .mm-fdesign__invisible-mark {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 幽灵占位:SortableJS 把 ghostClass 加在原卡片上(布局冻结,原位不动),
|
||||
显示为灰色占位块(内部控件隐藏),一眼看出「这里被拖走了」;
|
||||
跟手的是自绘跟手卡片(createDragBadge 生成,样式见全局 .mm-fdesign__drag-badge) */
|
||||
@@ -3135,13 +3340,6 @@ textarea.mm-fdesign__ctrl {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.mm-edit__section-count {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 画布分组删除按钮(标题行右侧):图标常驻显示 */
|
||||
.mm-fdesign__section-del {
|
||||
display: inline-flex;
|
||||
@@ -3167,31 +3365,6 @@ textarea.mm-fdesign__ctrl {
|
||||
}
|
||||
}
|
||||
|
||||
/* 分组标题行编辑按钮(✏ 修改标题):图标常驻显示 */
|
||||
.mm-fdesign__section-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--fms-control-radius, 5px);
|
||||
background: transparent;
|
||||
color: var(--fms-text-placeholder, #9ca3af);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--fms-secondary-hover, #f1f5f9);
|
||||
color: var(--fms-text);
|
||||
border-color: var(--fms-border);
|
||||
}
|
||||
}
|
||||
|
||||
/* 分组标题为空时的占位:灰色「空」,点击可设置标题 */
|
||||
.mm-fdesign__section-name.is-empty {
|
||||
color: var(--fms-text-placeholder, #9ca3af);
|
||||
@@ -3226,9 +3399,30 @@ textarea.mm-fdesign__ctrl {
|
||||
}
|
||||
}
|
||||
|
||||
/* 分组卡片拖拽占位 */
|
||||
/* 分组卡片拖拽占位:分组拖拽没有自绘跟手卡片(fallback 克隆被全局隐藏),
|
||||
* ghost 即用户看到的「被拖分组」本体——必须正常显示标题与字段内容,
|
||||
* 只加主色边框提示位置,区别于变淡的其他分组 */
|
||||
.mm-fdesign__section-ghost {
|
||||
opacity: 0.4;
|
||||
opacity: 1;
|
||||
border-color: color-mix(in srgb, var(--fms-primary) 55%, var(--fms-border)) !important;
|
||||
box-shadow: 0 2px 8px rgba(39, 54, 70, 0.06);
|
||||
}
|
||||
|
||||
/* 分组拖拽中:非拖拽的分组卡片变淡,突出拖拽焦点(与字段拖拽一致) */
|
||||
.mm-fdesign__canvas-wrap.is-section-drag-active
|
||||
.mm-fdesign__section:not(.mm-fdesign__section-ghost) {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 分组拖拽中:ghost 内的字段禁止 hover / 点击穿透——
|
||||
* 否则鼠标扫过被拖分组时字段会触发自己的 hover 样式,干扰拖拽视觉 */
|
||||
.mm-fdesign__canvas-wrap.is-section-drag-active .mm-fdesign__section-ghost {
|
||||
pointer-events: none;
|
||||
|
||||
.mm-fdesign__field {
|
||||
pointer-events: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* 分组级左右布局:标题在左(竖排),字段画布在右;仅影响单个分组 */
|
||||
@@ -3266,21 +3460,29 @@ textarea.mm-fdesign__ctrl {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 分组卡片容器:外层 48 宫格流式排布 ----
|
||||
* 每个分组按自身占格数(gridColumn: span N)排列,放不下自动换行;
|
||||
* 分组内部字段仍是独立 24 宫格(.mm-fdesign__grid) */
|
||||
.mm-fdesign__sections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(48, minmax(0, 1fr));
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mm-fdesign__sections .mm-fdesign__section {
|
||||
margin-top: 0 !important; /* grid gap 已提供间距,覆盖基础 margin-top */
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- 卡片标签行快捷开关(必填 / 显示)----
|
||||
默认隐藏,仅在卡片 hover 或选中时浮现,降低常驻视觉噪音;
|
||||
常驻显示(不再 hover / 选中才浮现),方便直接点按切换;
|
||||
图标灰色=未开启,主题色=开启 */
|
||||
.mm-fdesign__label-chips {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
margin-left: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.mm-fdesign__field:hover .mm-fdesign__label-chips,
|
||||
.mm-fdesign__field.is-selected .mm-fdesign__label-chips {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mm-fdesign__label-chip {
|
||||
@@ -3368,4 +3570,140 @@ textarea.mm-fdesign__ctrl {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* ---- 分组设置按钮(齿轮):打开分组设置弹窗 ---- */
|
||||
.mm-fdesign__section-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--fms-text-placeholder, #9ca3af);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: var(--fms-secondary-hover, #f1f5f9);
|
||||
color: var(--fms-text);
|
||||
border-color: var(--fms-border);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 分组设置弹窗 ---- */
|
||||
.mm-fdesign__section-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.mm-fdesign__section-settings-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-fdesign__section-settings-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.mm-fdesign__section-settings-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-fdesign__section-settings-steps {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mm-fdesign__section-settings-step {
|
||||
min-width: 34px;
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 5px;
|
||||
background: var(--fms-card);
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.15s,
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--fms-primary);
|
||||
color: var(--fms-primary);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: var(--fms-primary);
|
||||
background: color-mix(in srgb, var(--fms-primary) 10%, var(--fms-card));
|
||||
color: var(--fms-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.mm-fdesign__section-settings-input {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
/* ---- 表单预览(主表):运行时形态,用 Form/FormItem 渲染 ----
|
||||
* 外层 48 宫格与设计画布一致,分组按各自 cols 占格流式排布;
|
||||
* 分组内层 24 宫格由 FormItem 的 span 跨列实现。
|
||||
* 间距层次:分组间 > item 间,无边框时靠间距区分分组边界 */
|
||||
.mm-fdesign__preview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(48, minmax(0, 1fr));
|
||||
gap: 16px 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.mm-fdesign__preview-section {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mm-fdesign__preview-title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
.mm-fdesign__preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(24, minmax(0, 1fr));
|
||||
gap: 8px 10px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Form 上下文:预览只关心布局与控件,不渲染提交行为相关 UI */
|
||||
.mm-fdesign__preview-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 预览内 FormItem 纵向间距归零,由 grid gap 统一控制 */
|
||||
.mm-fdesign__preview-grid :deep(.fms-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.mm-fdesign__preview-empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -5,9 +5,7 @@
|
||||
* 语言列表使用前端常量(DEFAULT_LANGUAGES),后续可切换语言类型表。
|
||||
*/
|
||||
import { computed, h, ref, watch } from 'vue'
|
||||
import Message from '@/components/ui/message/message-manager'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
import { Message, Button, Input } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { createTempId } from '@/utils/tempId'
|
||||
import { cloneData } from '@/utils/dataChanges'
|
||||
|
||||
+1
-2
@@ -3,8 +3,7 @@
|
||||
* 列表配置面板(s_field_view):列宽/分组/显示/排序,支持行拖拽与分组管理。
|
||||
*/
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import Message from '@/components/ui/message/message-manager'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import { Message, Button } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
+1
-3
@@ -5,9 +5,7 @@
|
||||
*/
|
||||
import { computed, h, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { Languages } from '@lucide/vue'
|
||||
import Message from '@/components/ui/message/message-manager'
|
||||
import Modal from '@/components/ui/modal/modal.vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import { Message, Modal, Button } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { buildModuleItemI18nKey, syncModuleItemI18nKeys } from '@/utils/i18n'
|
||||
import { createTempId } from '@/utils/tempId'
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
* 查询配置面板(s_field_query):查询控件/操作符/默认值/启用/排序,支持行拖拽与新增。
|
||||
*/
|
||||
import { computed, ref, shallowRef, watch } from 'vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import { Button } from '@/components/ui'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
import { createTempId } from '@/utils/tempId'
|
||||
|
||||
|
||||
+1483
-2062
File diff suppressed because it is too large.
Load diff
@@ -24,15 +24,17 @@ import {
|
||||
Info,
|
||||
FolderTree,
|
||||
} from '@lucide/vue'
|
||||
import Message from '@/components/ui/message/message-manager'
|
||||
import Modal from '@/components/ui/modal/modal.vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
import Tree from '@/components/ui/tree/tree.vue'
|
||||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||||
import Splitter from '@/components/ui/splitter/splitter.vue'
|
||||
import SplitterPanel from '@/components/ui/splitter/panel.vue'
|
||||
import Tabs from '@/components/ui/tabs/tabs.vue'
|
||||
import {
|
||||
Message,
|
||||
Modal,
|
||||
Button,
|
||||
Input,
|
||||
Tree,
|
||||
Dropdown,
|
||||
Splitter,
|
||||
SplitterPanel,
|
||||
Tabs,
|
||||
} from '@/components/ui'
|
||||
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from '@/services/api'
|
||||
import { buildTree, sortTree } from '@/utils/tree'
|
||||
import { createTempId } from '@/utils/tempId'
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
defineOptions({ name: 'TestPage' })
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import { Button } from '@/components/ui'
|
||||
|
||||
const route = useRoute()
|
||||
const title = computed(() => route.meta?.title ?? '测试页')
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
import { registerFeature, useAreaSelection } from 'stk-table-vue'
|
||||
import { registerFeature, useAreaSelection, StkTable } from 'stk-table-vue'
|
||||
import FmsTable from '@/components/fms-table/FmsTable.vue'
|
||||
|
||||
// 与 main.js 一致:注册 stk-table 的按需 feature(行编辑依赖 area-selection)
|
||||
@@ -150,4 +150,33 @@ describe('fms-table 虚拟滚动验证', () => {
|
||||
expect(editor.exists()).toBe(true)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
// 列宽拖拽回归:库 useColResize 在 mouseUp 时「原地改写」传入的列对象 width 并
|
||||
// emit update:columns(同一批对象的新数组)。未绑定 v-model:columns 的父组件
|
||||
// 不会回灌新数组,displayColumns 必须自行换新数组引用,库才能重算出新列宽。
|
||||
// 复现方式:拿到库实际收到的 displayColumns,原地改第 1 列宽度后以同批对象
|
||||
// emit update:columns,断言表头渲染出新宽度。
|
||||
it('拖拽列宽后(未绑定 v-model:columns),新列宽立即生效不丢失', async () => {
|
||||
const columns = makeColumns()
|
||||
const wrapper = mount(FmsTable, {
|
||||
props: { columns, dataSource: makeRows(20), rowKey: 'id', seq: false },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await nextTick()
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
|
||||
const stkTable = wrapper.findComponent(StkTable)
|
||||
const displayColumns = stkTable.props('columns')
|
||||
const target = displayColumns.find((c) => c.dataIndex === 'c1')
|
||||
target.width = '333px' // 模拟 useColResize mouseUp 的原地改写
|
||||
|
||||
await stkTable.vm.$emit('update:columns', displayColumns.slice())
|
||||
await nextTick()
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
|
||||
// 表头 th 宽度经 --cw CSS 变量渲染;virtualX 模式下取自 __W__(dealColumns 重算产物)
|
||||
const th = wrapper.findAll('thead th').find((el) => el.attributes('data-col-key') === 'c1')
|
||||
expect(th.attributes('style')).toContain('333px')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
-115
@@ -1,115 +0,0 @@
|
||||
# 代码审查问题清单
|
||||
|
||||
> 审查范围:`ModuleEditConfigPanel.vue`(4510 行)、`index.vue`、`FieldGroupManager.vue`、`FmsTable.vue`
|
||||
> 日期:2026-08-26
|
||||
|
||||
---
|
||||
|
||||
## 一、功能性 bug(会真实出错)
|
||||
|
||||
### 1. 子表预览弹窗没有打开入口
|
||||
**P1**
|
||||
- `previewOpen` 定义于 `ModuleEditConfigPanel.vue:611`,弹窗渲染于 1998–2087 行
|
||||
- 全文件检索不到任何设置 `previewOpen = true` 的地方,也没有「预览」按钮
|
||||
- 弹窗 `:open` 永远为 false → **子表预览功能不可达**
|
||||
- `tableColumnRows`/`previewGroupHeader`/列宽拖拽手柄那一整套 WYSIWYG 预览全被闲置
|
||||
|
||||
### 2. `libGroup`/`columnGridGroup` 分组配置不一致(跨分组拖拽可能失效)
|
||||
- 字段库:`libGroup = { name: 'form', pull: 'clone', put: false }`(1461 行)
|
||||
- 子表列网格:`columnGridGroup = { name: 'form' }`(1464 行)→ 子表列之间可以互相移动
|
||||
- **主表画布网格:内联 `:group="{ name: 'form' }"`(2165 行),默认 `pull: true`** → 字段库(`put: false`)往主表画布放 → 会被 SortableJS 拒绝
|
||||
- 主表模式其实有自己的 `onLibDragEnd`(1368 行)兜底逻辑,注释也说"源=字段库,目标列表只收到 add/sort"
|
||||
- 这是一个「看起来会被拒、实际靠 onLibDragEnd 兜底」的双轨实现——逻辑相当绕,一旦某个 onMove/onStart/onEnd 时序变化就会暴露。**主表拖入的可靠性完全押在 sortable 事件顺序上**,比较脆弱。
|
||||
|
||||
### 3. 分组标题重命名有 blur/点击竞态(很可能被覆盖丢改动)
|
||||
- `commitEditGroupTitle` 同时绑定在输入框 `@blur` 和确认按钮 `@mousedown.prevent`(1806、1816、1824 行)
|
||||
- 在按钮上按下鼠标 → `@mousedown.prevent` 阻止了默认行为,不会先触发 blur,顺序基本 OK
|
||||
- 真正的坑是:**直接点「删除分组」或拖拽到其它分组时**,输入框先失焦提交、随后 `updateGroups`/`syncList` 把 props.groups 换成新数组,但 `groupTitleDraft` 只在 commit 里清。整体靠时序兜底,多步操作间容易丢改动(详见第 11 条联动问题)。
|
||||
|
||||
### 4. 表格模式下列宽与 `b_height` 语义混乱
|
||||
**P0**
|
||||
- `formDataColumns`(315 行)里 `b_height` 是「文本域高度」,但 `b_height` 会被 `normalizedRow` 统一 `Number()` 化(160 行);表格模式 `tableDataColumns` 没有 b_height 列
|
||||
- **`b_width` 在 diff/save 时会被完整提交**——它既不是后端字段也不是 UI 控件,只是一次拖拽调宽的临时状态,随保存写库会造成脏数据(后端 `s_field_edit` 若没有该列,`update` 会因"字段不存在"报错或写进多余列)
|
||||
- **栅格宽度口径不一致**:主表设计视图用「24 栅格」,但 `COLSPAN_OPTIONS`(428 行)写的是 `整行 24/24 / 2/3 行 16/24 / 半行 12/24…`——文本写着 24 栅格,但 `spanOf` 支持 1~24,`b_colspan` 默认 24。如果后端 `b_colspan` 存的是 12 制(计划文档 §10.2 说"12 栅格"),前端按 24 制渲染会导致整行永远多一倍宽。需要和后端表结构核对,目前前后端口径不一致的风险很高。
|
||||
|
||||
---
|
||||
|
||||
## 二、交互体验问题
|
||||
|
||||
### 5. 字段库「点击=添加」与「拖拽=添加」语义不一致
|
||||
**P1**
|
||||
- 点击字段库项 → `selectField`(862 行):**已加入的字段再点击会被移除**(867 行)
|
||||
- 拖拽字段到画布 → 已在表单中会被 `Message.warning` 拒绝(1386 行)
|
||||
- 同一动作两种结果,用户容易误删字段。建议点击已加入项改为「定位选中」,把移除收敛到 Delete/删除按钮。
|
||||
|
||||
### 6. 拖拽意图判定在窄卡片上极易误判
|
||||
- `updateDragIntent`(959 行)按「鼠标到卡片四条边的归一化最近距离」决定插入方向(1024-1030 行)
|
||||
- 字段卡片通常只有 1/2~1/4 行宽(约 150~300px),宽高比很大,**左右方向几乎总是胜出** → 用户想「拖到下一行」时总是落在同行前后
|
||||
- 加上 hover 自身只给上/下(998-1009 行),两种判定阈值不一致,操作学习成本高、误操作率高
|
||||
|
||||
### 7. 拖拽期间 `dragLine` 只清不锁
|
||||
- Esc 取消只清 `dragLine`/`dragOverSection`(125-129 行),SortableJS 的拖拽会话仍继续
|
||||
- **中途 Esc 后继续移动,`onDragShadowMove` 会重新计算 intent**,表现为"取消失效"
|
||||
|
||||
### 8. 撤销栈是「操作级」而非「原子级」,且深度受限
|
||||
- `pushHistory` 只压 `snapshotRows`(浅拷贝数组),相邻重复快照去重(99 行)
|
||||
- `onColspanInput`(1503)每次 input 事件压栈 → 连续输入数字产生 N 个撤销点
|
||||
- `onWidthInput`(533)同样逐键压栈
|
||||
- 列宽拖拽 `startColumnResize` 压一次但 `onColumnResizeMove` 每帧改 `row.b_width`,mouseup 时才 emit → **撤销栈里的中间宽度和最终宽度不一致**,Ctrl+Z 会跳到拖拽中间值
|
||||
- 这些组合下 30 条 HISTORY_LIMIT 很快就耗尽,且撤销点不直观
|
||||
|
||||
### 9. 删除字段的「撤销提示条」有 8 秒超时但无进度
|
||||
- `undoTimer` 8 秒后清 `lastDeleted`(1539 行),提示条消失**且无倒计时/动画提示**
|
||||
- 若用户晚于 8 秒想恢复,只能靠 Ctrl+Z(且此时 Ctrl+Z 撤销的是整表快照,会连带恢复已删除的字段布局,可能更糟)
|
||||
|
||||
### 10. 分组方向(左右/上下)开关被隐藏
|
||||
- `toggleLayout` 定义在 `FieldGroupManager.vue:111`,但只有「分组管理」弹窗里每个节点有该按钮(280-283 行)
|
||||
- 主画布上只有 hover 才出现的分组删除按钮,**画布上没有任何分组方向切换入口**——用户必须打开弹窗才能调左右布局,体验割裂
|
||||
|
||||
### 11. 删除分组直接改动 listConfig/editConfig,但不进撤销栈
|
||||
- `deleteGroupDirect`(760 行)会改 `props.listConfig`(`syncList`)并把字段移未分组
|
||||
- 但这些改动**不推入 history**,Ctrl+Z 无法回退分组删除本身(只能回退字段归属)
|
||||
|
||||
---
|
||||
|
||||
## 三、一致性与可维护性
|
||||
|
||||
### 12. `formDirection`/`globalDirection` 死代码
|
||||
**P0**
|
||||
- `formDirection = computed(() => globalDirection.value)`(644 行)引用的 `globalDirection` 全文件不存在
|
||||
- **该 computed 会在首次求值时抛 ReferenceError**,任何路径访问 `formDirection` 都会崩
|
||||
- 目前没人引用它所以没炸,但这是颗定时炸弹
|
||||
|
||||
### 13. 大量字段/状态重复定义
|
||||
- `FIELD_TYPE_LABELS`(416)在 `libraryGroups`(667)和 `controlTypeLabel`(468)各用一次,类型集散落在 `placeholderOf`(481)、`defaultWidthOf`(512)、`SAMPLE_TEXT`(628)、`field-control` 模板(2254-2298)四处,新增类型要改五个地方
|
||||
- 属性面板(主表 2527、子表 2385)几乎整段重复,只有宽度/高度两处不同
|
||||
- `COLSPAN_OPTIONS` 只在数据视图用(363 行 `editor: { type: 'select', options: () => COLSPAN_OPTIONS }`),**设计视图属性面板用的是 `mm-fdesign__span-control` 数字输入框**,两处口径(24 制 vs 数字 1-24)分开维护
|
||||
|
||||
### 14. `placeholderOf` 只读字段被忽略
|
||||
- `placeholderOf`(481 行)返回默认占位符,但 `placeholder` 在画布控件上是**只读展示**(`tabindex="-1"` + `pointer-events:none`)
|
||||
- 属性面板里根本没有占位符编辑入口(只有数据视图的 FmsTable 有 `b_placeholder` 列)
|
||||
- 即**设计视图里无法设置占位符**——功能存在但 UI 缺失
|
||||
|
||||
### 15. 快捷键在「预览(数据视图)」视图下静默失效
|
||||
- `onDesignerKeydown` 要求 `viewMode === 'design'`(1569 行),但数据视图的 FmsTable 里 Ctrl+C/V/Z 会被浏览器/表格接管
|
||||
- 设计视图的复制/撤销/Delete 快捷键全不生效,且没有任何提示(帮助浮层只在设计视图头部)
|
||||
- 用户切到数据视图按 Delete 想删字段会直接没反应
|
||||
|
||||
### 16. 无保存时「重载」与「切换」的确认文案不一致
|
||||
- `index.vue` 用两个不同的弹窗:重载用通用 `showConfirm`(236-244),切换用专门的 `switchConfirm`(332-341、1221-1236)
|
||||
- 两个弹窗行为基本一致但代码/文案/按钮布局重复
|
||||
- 切换弹窗缺「保存并切换失败时的错误提示」路径:`onSwitchSave` 里 `saveModule()` 返回 false 时直接留在弹窗,没有给用户反馈为什么失败
|
||||
|
||||
---
|
||||
|
||||
## 建议优先级
|
||||
|
||||
| 优先级 | 事项 | 理由 |
|
||||
|--------|------|------|
|
||||
| P0 | 修 `formDirection`/`globalDirection` 死代码(#12) | 一旦有人引用就崩 |
|
||||
| P0 | 统一 24/12 栅格口径,核对 `b_colspan` 后端字段(#4) | 前后端不一致=整行渲染错误 |
|
||||
| P1 | 给子表预览加入口或移除死代码(#1) | 功能不可达 |
|
||||
| P1 | 撤销栈改「原子操作」+ 修复列宽拖拽中间态(#8) | 直接影响日常编辑 |
|
||||
| P1 | 字段库点击/拖拽语义统一(#5) | 防误删 |
|
||||
| P2 | 拖拽意图判定、Esc 取消锁定、删除分组入撤销栈等(#6/#7/#11) | 打磨体验 |
|
||||
| P2 | 占位符编辑入口、分组方向画布入口、数据视图快捷键(#14/#10/#15) | 功能缺失 |
|
||||
Submodule
+1
Submodule code/test/form-js added at d00e32ab92.
Reference in new issue
Block a user