20260827173205

This commit is contained in:
oneao committed 2026-08-27 17:32:05 +08:00
1 parent 60cacfa8fb
commit 3824af0f12
38 files changed
+2398 -3552

No files matched your search

-1007
View File
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 -1
View File
@@ -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
+1 -1
View File
@@ -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)
}
}
+215 -121
View File
@@ -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>
+1 -1
View File
@@ -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'
+1 -5
View File
@@ -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'
@@ -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'
@@ -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'
@@ -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'
@@ -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({
@@ -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'
@@ -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'
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'
+1 -1
View File
@@ -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
View File
@@ -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) | 功能缺失 |
+1
Submodule code/test/form-js added at d00e32ab92.