This commit is contained in:
oneao committed 2026-10-14 22:24:12 +08:00
1 parent e434374db5
commit b4498e5350
31 files changed
+2007 -294

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

@@ -187,7 +187,10 @@ function onExpandLeave(el: Element): void {
</Transition>
</div>
<!-- 叶子:跳转链接 -->
<!-- 叶子:跳转链接。
折叠态只渲染图标:侧栏只剩 collapseWidth 那么宽,图标+间距就占掉二十几个像素,
名称会被裁成一两个字加省略号(比不显示更糟)。名称改由 title 提示,
与分组分支的折叠态(G3Dropdown 触发器里同样只有图标)保持一致。 -->
<RouterLink
v-else
class="layout-sidebar__item"
@@ -201,6 +204,6 @@ function onExpandLeave(el: Element): void {
@click="onLeafClick(item.path, $event)"
>
<component :is="item.icon" v-if="item.icon" class="layout-sidebar__icon" />
<span class="layout-sidebar__label">{{ item.title }}</span>
<span v-if="!collapsed" class="layout-sidebar__label">{{ item.title }}</span>
</RouterLink>
</template>
+4 -3
View File
@@ -367,12 +367,13 @@ async function onSubmit(): Promise<void> {
<G3FormItem name="password" label="密码">
<!-- type="password" 时 G3Input 自带「显示 / 隐藏密码」按钮,无需自绘。
密码非必填(对应 formRules 里没有 password 规则,label 也不会带红色星号),
故占位文案标注「选填」,避免用户以为漏填会被拦下。 -->
占位文案不写「选填」:与机构码 / 账号保持一致,避免暗示密码可随手填。
注意密码【确实】不做前端必填校验(见 formRules 注释),
所以这里只是文案统一,并未改变「空密码也能提交」的行为。 -->
<G3Input
v-model="formModel.password"
type="password"
placeholder="请输入密码(选填)"
placeholder="请输入密码"
autocomplete="current-password"
:maxlength="32"
>
@@ -0,0 +1,342 @@
<script setup lang="ts">
/**
* 自动编码面板(s_autocode):单条规则表单 + 实时预览(移植自 fms-vue)。
*
* 数据契约:v-model:autocode 绑定父级持有的规则草稿(s_autocode 行的数组,主键 b_module_id + b_field)。
* 一个模块最多一条规则,面板就地修改行字段(改动直接落在父级草稿上,保存时才 diff 提交)。
* b_currentperiod / b_currentvalue 是运行时流水状态(服务端维护),预览读取它们但从不回写。
*
* 惰性建行:模块从未配置自动编码时,草稿保持空数组——打开面板即建行会让「无变更」
* 被 diff 成一条 insert;占位行只用于渲染与校验,用户首次操作(拨开关或改参数)才落行。
*
* 布局(5 列紧凑网格 + 纵排 label,横排时 88px 的标签会把控件压到几十像素):
* ┌ 第一行:启用 / 业务编号字段 / 重置周期
* ├ 第二行:前缀 / 日期 / 分隔符 / 位数 / 起始值
* └ 第三行:编号预览(跨整行)
*/
import { computed, reactive, ref } from 'vue'
import { G3Form, G3FormItem, G3Input, G3Select, G3Switch } from '@g3soft/ui'
import type { FormRule } from '@g3soft/ui'
import type { ModuleAutoCodeRow, ModuleFieldRow } from './utils'
const props = withDefaults(
defineProps<{
/** 模块编码(保存时写入规则行的 b_module_id) */
moduleId: string
/** 本模块字段清单(s_field):业务编号字段下拉的候选与类型校验依据 */
fields?: ModuleFieldRow[]
/** 父级忙碌态(加载 / 保存中):期间锁掉全部控件 */
loading?: boolean
}>(),
{ fields: () => [], loading: false },
)
/** 自动编码规则草稿;空数组表示本模块未配置自动编码 */
const autoCodeModel = defineModel<ModuleAutoCodeRow[]>('autocode', { default: () => [] })
const DATE_FORMAT_OPTIONS = [
{ label: '不使用日期', value: '' },
{ label: 'yyyy', value: 'yyyy' },
{ label: 'yy', value: 'yy' },
{ label: 'yyyyMM', value: 'yyyyMM' },
{ label: 'yyMM', value: 'yyMM' },
{ label: 'yyyyMMdd', value: 'yyyyMMdd' },
{ label: 'yyyy-MM', value: 'yyyy-MM' },
{ label: 'yyyy-MM-dd', value: 'yyyy-MM-dd' },
{ label: 'yyyy/MM', value: 'yyyy/MM' },
{ label: 'yyyy/MM/dd', value: 'yyyy/MM/dd' },
]
const RESET_OPTIONS = [
{ label: '不重置', value: 'none' },
{ label: '每年', value: 'year' },
{ label: '每月', value: 'month' },
{ label: '每日', value: 'day' },
]
/** 业务编号字段候选:启用中的文本类字段(编号要往这个字段写字符串) */
function pickTextFieldOptions(fields: ModuleFieldRow[]) {
return (fields || []).filter(
(item) =>
Number(item.b_canuse) === 1 &&
String(item.b_field) !== 'b_id' &&
(item.b_type === 'input' || item.b_type === 'textarea'),
)
}
/** 新建规则行的默认值(未启用态):日期 / 位数 / 重置周期给出常用初值 */
function createDraft(
moduleId: string,
fields: ModuleFieldRow[],
enabled = false,
): ModuleAutoCodeRow {
const firstField = pickTextFieldOptions(fields)[0]
return {
b_module_id: String(moduleId),
b_field: firstField?.b_field != null ? String(firstField.b_field) : null,
b_prefix: '',
b_dateformat: 'yyyyMM',
b_separator: '-',
b_seqwidth: 5,
b_resettype: 'month',
b_startvalue: 1,
b_currentperiod: null,
b_currentvalue: 0,
b_canuse: enabled ? 1 : 0,
}
}
/** 已落草稿的规则行;模块从未配置过自动编码时为 null */
const localRow = computed<ModuleAutoCodeRow | null>(() =>
autoCodeModel.value.length ? autoCodeModel.value[0] : null,
)
/**
* 未配置时的渲染占位行:只用于展示与校验,不写回 model,
* 避免打开面板即建行导致「无变更却 diff 出一条 insert」。
*/
const emptyRow = computed(() => createDraft(props.moduleId, props.fields, false))
/** 表单绑定行:优先真实行,未配置时回退占位行,保证「启用」开关始终可见可拨 */
const formRow = computed<ModuleAutoCodeRow>(() => localRow.value ?? emptyRow.value)
const autoCodeEnabled = computed(() => Number(localRow.value?.b_canuse) === 1)
/* ---------- 校验规则:错误直接显示在对应 FormItem 下方 ---------- */
const formRules = computed<Record<string, FormRule[]>>(() => {
const field = (props.fields || []).find(
(item) => String(item.b_field) === String(formRow.value?.b_field || ''),
)
return {
b_field: [
{
validator: (value) => {
if (!autoCodeEnabled.value) return true
if (!value) return '请选择业务编号字段'
if (!field || Number(field.b_canuse) !== 1) return '请选择已启用的业务编号字段'
if (field.b_type !== 'input' && field.b_type !== 'textarea') {
return '业务编号字段必须使用文本控件类型'
}
return true
},
},
],
b_startvalue: [
{
type: 'integer',
validator: (value) => {
if (!autoCodeEnabled.value) return true
if (value === '' || value == null) return '请填写起始值'
if (Number(value) < 0) return '起始值必须是大于或等于 0 的整数'
return true
},
message: '起始值必须是大于或等于 0 的整数',
},
],
b_resettype: [{ required: true, message: '请选择重置周期' }],
b_seqwidth: [
{
validator: (value) => {
if (!autoCodeEnabled.value) return true
const width = Number(value)
if (value === '' || value == null) return '请填写流水号位数'
if (!Number.isInteger(width) || width < 1 || width > 18) {
return '流水号位数必须是 1 到 18 之间的整数'
}
return true
},
},
],
}
})
const formRef = ref<InstanceType<typeof G3Form> | null>(null)
/** 校验全表:返回 [{ name, errors }],空数组即通过(供保存前调用) */
async function validate() {
return (await formRef.value?.validateAll()) ?? []
}
defineExpose({ validate })
/** 业务编号字段下拉候选 */
const fieldOptions = computed(() =>
pickTextFieldOptions(props.fields).map((field) => ({
label: `${field.b_name || field.b_field} (${field.b_field})`,
value: String(field.b_field),
})),
)
/**
* 惰性建行:用户首次操作(拨「启用」或改任意参数)时才落一行到草稿。
* defineModel 写入后 prop 要下一轮才回传,紧接着回读仍是旧数组(空),
* 直接取 [0] 会得到 undefined 再写属性就炸——先包成 reactive 再写回,
* 返回其中的代理行,保证后续就地改字段能被追踪。
*/
function ensureRow(): ModuleAutoCodeRow {
if (autoCodeModel.value.length) return autoCodeModel.value[0]
const rows = reactive([createDraft(props.moduleId, props.fields, false)])
autoCodeModel.value = rows
return rows[0]
}
function toggleEnabled(checked: boolean) {
ensureRow().b_canuse = checked ? 1 : 0
}
/** 就地写草稿行字段:ensureRow 保证行已存在(用户首次操作时才落行) */
function setRowValue(field: string, value: unknown) {
ensureRow()[field] = value
}
function pad(value: number): string {
return String(value).padStart(2, '0')
}
function formatDate(date: Date, format: string): string {
const yyyy = String(date.getFullYear())
const yy = yyyy.slice(-2)
const MM = pad(date.getMonth() + 1)
const dd = pad(date.getDate())
return String(format || '')
.replaceAll('yyyy', yyyy)
.replaceAll('yy', yy)
.replaceAll('MM', MM)
.replaceAll('dd', dd)
}
/** 当前周期标识:与规则同口径,用于判断存量流水是否已跨周期(跨了就从起始值重来) */
function currentPeriod(date: Date, resetType: string): string {
const yyyy = String(date.getFullYear())
const MM = pad(date.getMonth() + 1)
const dd = pad(date.getDate())
if (resetType === 'none') return '*'
if (resetType === 'year') return yyyy
if (resetType === 'day') return yyyy + MM + dd
return yyyy + MM
}
/** 编码预览:前缀 + 日期 + 补位流水号,按分隔符拼接 */
const preview = computed(() => {
const row = formRow.value
if (!row) return ''
const today = new Date()
const startValue = Number(row.b_startvalue) || 1
const storedValue = Number(row.b_currentvalue) || 0
const activePeriod = currentPeriod(today, row.b_resettype)
const sequence = row.b_currentperiod === activePeriod ? storedValue + 1 : startValue
const parts: string[] = []
if (row.b_prefix) parts.push(row.b_prefix)
const dateText = formatDate(today, row.b_dateformat || '')
if (dateText) parts.push(dateText)
parts.push(String(sequence).padStart(Number(row.b_seqwidth) || 1, '0'))
return parts.join(row.b_separator || '')
})
</script>
<template>
<!-- 固定竖排:这里 5 列紧凑网格(每列约 130px),横排时 88px 的标签会把控件压到几十像素。
显式传 layout 优先于全局配置(中文默认横排),只锁这一个面板。
标签左对齐走 G3Form 的默认值(竖排下 label 占满整列,右对齐会把文字推到边缘)。 -->
<G3Form
ref="formRef"
class="mm-auto-code"
:model="formRow"
:rules="formRules"
layout="vertical"
:columns="5"
max-width="720px"
>
<!-- 第一行:启用 / 业务编号字段 / 重置周期 -->
<G3FormItem label="启用" name="b_canuse">
<G3Switch
:model-value="autoCodeEnabled"
:disabled="loading"
@update:model-value="toggleEnabled"
/>
</G3FormItem>
<G3FormItem label="业务编号字段" name="b_field" :span="2">
<G3Select
:model-value="String(formRow.b_field || '')"
:options="fieldOptions"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(value) => setRowValue('b_field', value)"
/>
</G3FormItem>
<G3FormItem label="重置周期" name="b_resettype" :span="2">
<G3Select
:model-value="formRow.b_resettype"
:options="RESET_OPTIONS"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(value) => setRowValue('b_resettype', value)"
/>
</G3FormItem>
<!-- 第二行:前缀 / 日期 / 分隔符 / 位数 / 起始值 -->
<G3FormItem label="前缀" name="b_prefix">
<G3Input
:model-value="formRow.b_prefix ?? ''"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(value) => setRowValue('b_prefix', value)"
/>
</G3FormItem>
<G3FormItem label="日期" name="b_dateformat">
<G3Select
:model-value="formRow.b_dateformat ?? ''"
:options="DATE_FORMAT_OPTIONS"
:disabled="loading || !autoCodeEnabled"
allow-free-input
@update:model-value="(value) => setRowValue('b_dateformat', value)"
/>
</G3FormItem>
<G3FormItem label="分隔符" name="b_separator">
<G3Input
:model-value="formRow.b_separator ?? ''"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(value) => setRowValue('b_separator', value)"
/>
</G3FormItem>
<G3FormItem label="位数" name="b_seqwidth">
<G3Input
:model-value="formRow.b_seqwidth"
type="number"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(value) => setRowValue('b_seqwidth', value)"
/>
</G3FormItem>
<G3FormItem label="起始值" name="b_startvalue">
<G3Input
:model-value="formRow.b_startvalue"
type="number"
:disabled="loading || !autoCodeEnabled"
@update:model-value="(value) => setRowValue('b_startvalue', value)"
/>
</G3FormItem>
<!-- 第三行:编号预览(只读,跨整行;未启用时整体淡化) -->
<G3FormItem
label="编号预览"
name="b_preview"
:span="5"
class="mm-auto-code__preview-item"
:class="{ 'is-disabled': !autoCodeEnabled }"
>
<G3Input :model-value="preview" readonly :disabled="loading" />
</G3FormItem>
</G3Form>
</template>
<style lang="scss" scoped>
.mm-auto-code {
padding: 4px 0 16px;
}
/* 未启用自动编码:预览整体淡化 */
.mm-auto-code__preview-item {
transition: opacity 150ms ease;
}
.mm-auto-code__preview-item.is-disabled {
opacity: 0.55;
}
</style>
@@ -7,23 +7,20 @@
* 工具)与「同步字段」(describeApi + 合并计划 + 失效节点回收)随后续步骤接入,
* 本步只做表格与列编辑。
*
* 「同步字段」按钮上提到了页面级的面板头(右下内容卡的右上操作区),与其它面板的动作
* 放同一处,所以本面板不再自带工具条;真正的同步链路落地时由面板通过事件 / ref 提供动作。
*
* 列说明:字段名只读(b_field 即数据库列名,同步时对齐);字段名称 / 业务类型 / 启用
* 可编辑(启用列的编辑器写 1/0,与 b_canuse 口径一致);多语言键为只读展示
* (field.{模块编码}.{字段名} 的派生键物化随保存编排,不在加载时动草稿)。
*/
import { computed, ref } from 'vue'
import { RefreshCw } from '@lucide/vue'
import { G3Button, G3Message, G3Table } from '@g3soft/ui'
import { ref } from 'vue'
import { G3Table } from '@g3soft/ui'
import type { G3TableColumn, TableRow } from '@g3soft/ui'
import type { ModuleFieldRow, ModuleNode } from './utils'
import type { ModuleFieldRow } from './utils'
const fieldsModel = defineModel<ModuleFieldRow[]>('fields', { default: () => [] })
const props = defineProps<{
/** 模块主表行(只读):「同步字段」按 b_view_table 是否存在决定可用性 */
module?: ModuleNode | null
}>()
/** 业务类型候选(v2 的 s_field 只存业务元数据,不存物理结构) */
const FIELD_TYPES = [
{ label: '输入框', value: 'input' },
@@ -58,25 +55,10 @@ const columns = ref<G3TableColumn<TableRow>[]>([
/** 当前高亮行(点行高亮,与原型口径一致) */
const activeKey = ref<string | null>(null)
/** 同步按钮只在配置了查询表/视图时出现(没有同步源) */
const syncVisible = computed(() => Boolean(props.module?.b_view_table))
/** 同步字段:按数据库查询表/视图结构对齐字段定义(随后续步骤移植) */
function onSync() {
G3Message.info('字段同步随后续步骤移植')
}
</script>
<template>
<div class="mm-fields">
<div v-if="syncVisible" class="mm-fields__toolbar">
<G3Button type="outline" title="按数据库查询表/视图结构对齐字段定义" @click="onSync">
<template #icon><RefreshCw :size="14" /></template>
同步字段
</G3Button>
</div>
<div class="mm-fields__table">
<G3Table
v-model:columns="columns"
@@ -91,7 +73,7 @@ function onSync() {
</template>
<style lang="scss" scoped>
/* 面板纵向结构:工具条(可无)→ 撑满的表格区 */
/* 面板纵向结构:只有撑满的表格区(工具条已上提到页面级面板头) */
.mm-fields {
display: flex;
flex: 1;
@@ -99,13 +81,6 @@ function onSync() {
min-height: 0;
}
.mm-fields__toolbar {
display: flex;
flex: none;
gap: 8px;
padding: 0 0 8px;
}
.mm-fields__table {
flex: 1;
min-height: 0;
@@ -10,24 +10,8 @@
*
* 模块类型(s_module.b_module_type):module 模块 / data 数据模块 / virtual 虚拟模块。
*/
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch, type Component } from 'vue'
import {
CircleSlash,
Ellipsis,
FolderTree,
Globe,
Hash,
Info,
List,
Pencil,
RefreshCw,
Save,
Search,
ShieldCheck,
Table2,
Trash2,
Waypoints,
} from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { Ellipsis, FolderTree, RefreshCw, Save, Trash2 } from '@lucide/vue'
import {
G3Button,
G3Dropdown,
@@ -55,6 +39,7 @@ import {
sortTree,
type TreeRow,
} from '@/utils/tree'
import ModuleAutoCodePanel from './ModuleAutoCodePanel.vue'
import ModuleBasicPanel from './ModuleBasicPanel.vue'
import ModuleFieldsPanel from './ModuleFieldsPanel.vue'
import ModuleTreePanel from './ModuleTreePanel.vue'
@@ -65,6 +50,7 @@ import {
buildModuleItemI18nKey,
collectSchemaGroupKeys,
sqlStringLiteral,
type ModuleAutoCodeRow,
type ModuleFieldRow,
type ModuleNode,
} from './utils'
@@ -240,7 +226,8 @@ type SectionScope = 'always' | 'config' | 'data'
interface ConfigSection {
key: string
label: string
icon: Component
/** 面板头里标题下面的一句说明(右下内容卡的左上角) */
desc: string
scope: SectionScope
}
@@ -248,18 +235,75 @@ interface ConfigSection {
* 右侧配置区块定义:数组顺序即标签顺序。
* 原型的分组(概览 / 数据结构 / 界面配置 / 业务能力 / 高级设置)只用来决定先后顺序,
* 平铺成一行标签、不显示组名——10 个标签 + 组名会多一层噪音。
*
* 标签不带图标:数据模块下 10 个标签全显示,图标让每个标签多出 18px、
* 总宽超出卡片可用宽度(末两个标签被截、而标签栏滚动条是隐藏的),
* 且 Table2 / List / Search 这类图标本身也分不出来。
*
* desc 用于右下内容卡的面板头(左上标题 + 说明),与标签栏分离:
* 标签栏只回答「在哪一页」,面板头回答「这一页是干什么的」。
*/
const CONFIG_SECTIONS: ConfigSection[] = [
{ key: 'basic', label: '基础信息', icon: Info, scope: 'always' },
{ key: 'fields', label: '字段定义', icon: Table2, scope: 'config' },
{ key: 'edit', label: '表单设计', icon: Pencil, scope: 'config' },
{ key: 'list', label: '列表配置', icon: List, scope: 'config' },
{ key: 'query', label: '查询配置', icon: Search, scope: 'config' },
{ key: 'auto-code', label: '自动编码', icon: Hash, scope: 'data' },
{ key: 'power', label: '权限', icon: ShieldCheck, scope: 'data' },
{ key: 'relation', label: '模块关联', icon: Waypoints, scope: 'data' },
{ key: 'rule', label: '删除规则', icon: CircleSlash, scope: 'data' },
{ key: 'i18n', label: '多语言', icon: Globe, scope: 'always' },
{
key: 'basic',
label: '基础信息',
desc: '维护模块编码、名称、类型与数据来源。',
scope: 'always',
},
{
key: 'fields',
label: '字段定义',
desc: '维护模块字段、业务类型与启用状态。',
scope: 'config',
},
{
key: 'edit',
label: '表单设计',
desc: '设计录入表单的布局与字段排布。',
scope: 'config',
},
{
key: 'list',
label: '列表配置',
desc: '配置列表显示列、筛选项与默认排序。',
scope: 'config',
},
{
key: 'query',
label: '查询配置',
desc: '配置查询条件与默认查询参数。',
scope: 'config',
},
{
key: 'auto-code',
label: '自动编码',
desc: '配置编号生成规则、编码片段与流水号策略。',
scope: 'data',
},
{
key: 'power',
label: '权限',
desc: '按角色配置模块的访问与数据范围。',
scope: 'data',
},
{
key: 'relation',
label: '模块关联',
desc: '维护本模块与其他模块的引用关系。',
scope: 'data',
},
{
key: 'rule',
label: '删除规则',
desc: '配置删除单据时的连带处理规则。',
scope: 'data',
},
{
key: 'i18n',
label: '多语言',
desc: '集中维护模块、字段与提示语的各语言译文。',
scope: 'always',
},
]
const isDataModule = computed(() => selectedModule.value?.b_module_type === 'data')
@@ -277,14 +321,11 @@ const configSections = computed(() =>
),
)
/** G3Tabs 的 items(label 用字符串保底;图标在 #label 插槽里按 key 查回来渲染) */
/** G3Tabs 的 items:标签只用文字,图标不再带(见 CONFIG_SECTIONS 的说明) */
const configNavItems = computed(() =>
configSections.value.map(({ key, label }) => ({ key, label })),
)
const sectionByKey = (key: unknown) =>
CONFIG_SECTIONS.find((section) => section.key === String(key))
/** 当前激活的配置区块 */
const activeSection = ref('basic')
@@ -303,10 +344,17 @@ const headModule = computed(() => moduleDraft.value ?? selectedModule.value)
/** 选中模块的字段清单(「数据范围字段」下拉候选,s_field 的最小投影) */
const moduleFields = ref<ModuleFieldRow[]>([])
/** 选中变化:重建草稿并拉字段清单(异步响应带过期保护,切走即丢弃) */
/** 选中模块的自动编码规则草稿(s_autocode,一模块至多一条;空数组即未配置) */
const moduleAutoCode = ref<ModuleAutoCodeRow[]>([])
/** 自动编码面板实例:保存前调它的 validate() 拿表单错误 */
const autoCodePanelRef = ref<InstanceType<typeof ModuleAutoCodePanel> | null>(null)
/** 选中变化:重建草稿并拉字段 / 自动编码(异步响应带过期保护,切走即丢弃) */
watch(moduleSelectedId, (id) => {
moduleDraft.value = id == null ? null : toModuleDraft(String(id))
void loadModuleFields(id)
void loadModuleAutoCode(id)
})
function toModuleDraft(id: string): ModuleNode | null {
@@ -332,6 +380,29 @@ async function loadModuleFields(id: string | null) {
}
}
/**
* 加载本模块的自动编码规则(s_autocode)。
* 只有数据模块有保存表、编号才有落库位置,其余模块直接按「未配置」处理,不发请求。
*/
async function loadModuleAutoCode(id: string | null) {
moduleAutoCode.value = []
if (id == null) return
if (findModuleNode(id)?.b_module_type !== 'data') return
const requested = String(id)
try {
const rows = await loadDataApi(
's_autocode',
`b_module_id = ${sqlStringLiteral(requested)}`,
'b_field ASC',
)
// 选择已切走时丢弃过期响应
if (String(moduleSelectedId.value ?? '') !== requested) return
moduleAutoCode.value = (rows ?? []) as ModuleAutoCodeRow[]
} catch {
// 规则加载失败不打断页面:面板按「未配置」展示
}
}
/* ================= 卡头动作 ================= */
/** 卡头「更多」菜单:模块级动作(删除与左侧树右键菜单是同一入口) */
@@ -345,8 +416,34 @@ function onMoreAction({ key }: DropdownClickPayload) {
if (id != null) void deleteModule(String(id))
}
/** 卡头「保存」:保存编排(校验 → 逐表 diff → 重载)随配置面板一起移植 */
function onSave() {
/**
* 自动编码的跨面板前置条件:启用编码的模块必须先配置保存表。
* 编号是在保存记录时写进去的,没有保存表就没有落库位置,规则配了也不会生效。
*/
function validateAutoCode(): string {
const rule = moduleAutoCode.value[0]
if (!rule || Number(rule.b_canuse) !== 1) return ''
if (!String(moduleDraft.value?.b_save_table ?? '').trim()) return '启用自动编码前请先配置保存表'
return ''
}
/**
* 卡头「保存」:保存编排(校验 → 逐表 diff → 重载)随配置面板一起移植。
* 目前先把自动编码的校验串上:有错就跳到对应标签并高亮。
*/
async function onSave() {
const autoCodeErrors = await autoCodePanelRef.value?.validate()
if (autoCodeErrors?.length) {
activeSection.value = 'auto-code'
G3Message.warning('请修正表单中标红的错误项')
return
}
const autoCodeError = validateAutoCode()
if (autoCodeError) {
activeSection.value = 'auto-code'
G3Message.warning(autoCodeError)
return
}
G3Message.info('保存编排随配置面板逐步移植')
}
@@ -355,6 +452,17 @@ function onRefresh() {
G3Message.info('配置重新加载随配置面板逐步移植')
}
/**
* 字段定义面板的「同步字段」:按数据库查询表 / 视图结构对齐字段定义。
*
* 按钮挂在页面级的面板头(右下内容卡的右上操作区),与其它面板的动作放同一处;
* 真正的同步链路(describeApi + 合并计划 + 失效节点回收)落地时再回到
* ModuleFieldsPanel —— 届时由面板通过事件 / ref 提供动作,这里保持薄壳。
*/
function onSyncFields() {
G3Message.info('字段同步随后续步骤移植')
}
/** 收集删除模块时需要清理的 s_i18n 资源键(字段键 + 界面配置分组键) */
async function collectModuleI18nKeys(moduleId: string): Promise<string[]> {
const keys: string[] = []
@@ -540,52 +648,54 @@ onMounted(() => {
<G3SplitterPanel>
<main class="mm-page__main">
<section v-if="selectedModule" class="mm-card">
<!-- 卡头常驻:左侧模块信息,右侧动作按钮(刷新 / 保存 / 更多),不随配置区滚动 -->
<header class="mm-card__head">
<div class="mm-card__info">
<span class="mm-card__name" :title="headModule?.b_name || ''">
{{ headModule?.b_name || '未命名模块' }}
</span>
<G3Tag :variant="Number(headModule?.b_canuse) === 1 ? 'success' : 'danger'">
{{ Number(headModule?.b_canuse) === 1 ? '启用' : '停用' }}
</G3Tag>
<span v-if="unsavedModuleIds.has(String(headModule?.b_id))" class="mm-card__draft">
未保存·保存流程随配置面板移植
</span>
</div>
<div class="mm-card__actions">
<G3Button type="outline" title="重新加载当前模块配置" @click="onRefresh">
<template #icon><RefreshCw :size="14" /></template>
刷新
</G3Button>
<G3Button type="primary" title="保存当前模块的全部配置" @click="onSave">
<template #icon><Save :size="14" /></template>
保存
</G3Button>
<G3Dropdown :items="moreMenuItems" @click="onMoreAction">
<G3Button type="outline" title="更多操作" aria-label="更多操作">
<Ellipsis :size="16" />
<!-- 上块:卡头(模块信息 + 操作)与配置导航是一体的,同一张白卡 -->
<div class="mm-card__top">
<!-- 卡头常驻:左侧模块信息,右侧动作按钮(刷新 / 保存 / 更多),不随内容区滚动 -->
<header class="mm-card__head">
<div class="mm-card__info">
<span class="mm-card__name" :title="headModule?.b_name || ''">
{{ headModule?.b_name || '未命名模块' }}
</span>
<G3Tag :variant="Number(headModule?.b_canuse) === 1 ? 'success' : 'danger'">
{{ Number(headModule?.b_canuse) === 1 ? '启用' : '停用' }}
</G3Tag>
<span
v-if="unsavedModuleIds.has(String(headModule?.b_id))"
class="mm-card__draft"
>
未保存·保存流程随配置面板移植
</span>
</div>
<div class="mm-card__actions">
<G3Button type="outline" title="重新加载当前模块配置" @click="onRefresh">
<template #icon><RefreshCw :size="14" /></template>
刷新
</G3Button>
</G3Dropdown>
</div>
</header>
<!-- 配置导航:横向标签,只渲染标签栏;标签集随模块类型动态增减,
溢出时标签栏自身横向滚动(G3Tabs 内置) -->
<G3Tabs
v-model="activeSection"
nav-only
:items="configNavItems"
class="mm-config-nav"
aria-label="模块配置导航"
>
<template #label="{ item }">
<component :is="sectionByKey(item)?.icon" :size="14" class="mm-config-nav__icon" />
<span>{{ sectionByKey(item)?.label }}</span>
</template>
</G3Tabs>
<G3Button type="primary" title="保存当前模块的全部配置" @click="onSave">
<template #icon><Save :size="14" /></template>
保存
</G3Button>
<G3Dropdown :items="moreMenuItems" @click="onMoreAction">
<G3Button type="outline" title="更多操作" aria-label="更多操作">
<Ellipsis :size="16" />
</G3Button>
</G3Dropdown>
</div>
</header>
<!-- 卡头与配置导航之间的分割线:通栏(横贯整张白卡) -->
<div class="mm-card__divider" aria-hidden="true" />
<!-- 配置导航:横向标签,只渲染标签栏;标签集随模块类型动态增减,
溢出时标签栏自身横向滚动(G3Tabs 内置) -->
<G3Tabs
v-model="activeSection"
nav-only
:items="configNavItems"
class="mm-config-nav"
aria-label="模块配置导航"
/>
</div>
<!-- 内容区:所有 pane 常驻、v-show 切换——切标签不丢草稿与滚动位置;
具体面板按 fms-vue 逐节填进对应 pane -->
<!-- 下块:内容白卡。切标签换的是这里的内容(不同面板的表单) -->
<div class="mm-config-stack">
<div
v-for="section in configSections"
@@ -593,6 +703,27 @@ onMounted(() => {
:key="section.key"
class="mm-config-pane"
>
<!-- 面板头:左上标题 + 说明,右上该面板自己的操作 -->
<div class="mm-panel-head">
<div class="mm-panel-head__text">
<div class="mm-panel-head__title">{{ section.label }}</div>
<div class="mm-panel-head__desc">{{ section.desc }}</div>
</div>
<div
v-if="section.key === 'fields' && moduleDraft?.b_view_table"
class="mm-panel-head__actions"
>
<G3Button
type="outline"
title="按数据库查询表/视图结构对齐字段定义"
@click="onSyncFields"
>
<template #icon><RefreshCw :size="14" /></template>
同步字段
</G3Button>
</div>
</div>
<!-- 基础信息:第一个真实面板(保存编排落地前先只做 UI,编辑只落在本地草稿) -->
<ModuleBasicPanel
v-if="section.key === 'basic'"
@@ -600,11 +731,18 @@ onMounted(() => {
:fields="moduleFields"
:code-readonly="!unsavedModuleIds.has(String(selectedModule.b_id))"
/>
<!-- 字段定义:可编辑表格(参与勾选与同步字段随后续步骤接入) -->
<!-- 字段定义:可编辑表格(勾选与同步字段随后续步骤接入) -->
<ModuleFieldsPanel
v-else-if="section.key === 'fields'"
v-model:fields="moduleFields"
:module="moduleDraft"
/>
<!-- 自动编码:仅数据模块(configSections 已按 scope 过滤),单条规则 + 实时预览 -->
<ModuleAutoCodePanel
v-else-if="section.key === 'auto-code'"
ref="autoCodePanelRef"
v-model:autocode="moduleAutoCode"
:module-id="String(moduleDraft?.b_id ?? '')"
:fields="moduleFields"
/>
<div v-else class="mm-config-placeholder">
{{ section.label }}面板将按 fms-vue 逐步移植
@@ -648,25 +786,44 @@ onMounted(() => {
min-height: 0;
}
/* 右侧白卡不带边框(与左树面板一致);滚动交给当前显示的配置 pane */
/* 右侧分两块,各是一张白卡:
* 上块 —— 卡头(模块信息 + 操作)与配置导航一体
* 下块 —— 内容卡,切标签换的就是这里的表单内容
* 外层只是「分栏容器」:不带底色、不带圆角,只负责上下堆叠与间距。 */
.mm-card {
display: flex;
flex: 1;
flex-direction: column;
gap: 12px;
min-height: 0;
padding: 16px;
overflow: hidden;
}
/* 上块:卡头 + 分割线 + 配置导航。
* 内边距不放在本块上:分割线要通栏(横贯整张白卡),左右留白交给卡头与标签栏各自负责;
* 底部也不留 —— 标签栏贴住本块底边,选中项那条 2px 主色下划线正好落在底边上。 */
.mm-card__top {
display: flex;
flex: none;
flex-direction: column;
background: var(--g3-color-bg-container, #fff);
border-radius: var(--g3-radius-base, 0.5rem);
}
/* 卡头常驻:左侧模块信息,右侧动作按钮;不随配置区滚动 */
/* 卡头常驻:左上标题、右上操作;不随内容区滚动 */
.mm-card__head {
display: flex;
align-items: center;
gap: 8px;
flex: none;
/* 下内边距 12px:与分割线之间留出呼吸 */
padding: 16px 16px 12px;
}
/* 卡头与内容之间的分割线:通栏、与页面其它分隔线同色 */
.mm-card__divider {
flex: none;
height: 1px;
background: var(--g3-color-border, #e5e6eb);
}
.mm-card__info {
@@ -700,18 +857,62 @@ onMounted(() => {
margin-left: auto;
}
/* 配置导航(横向标签,nav-only):标签栏自身可横向滚动,窄屏不会把卡片撑破 */
/* 配置导航(横向标签,nav-only):标签栏自身可横向滚动,窄屏不会把卡片撑破。
* 左右留白自己负责(不在 .mm-card__top 上),上下不留 —— 标签栏贴住上块底边。
* 那条 1px 底线压成透明:块内已经有卡头分割线,再来一条就是第二条边界;
* 悬停变深 / 选中主色的反馈不受影响(那条走 --g3-color-primary)。 */
.mm-config-nav {
flex: none;
padding: 0 16px;
--g3-border-color: transparent;
}
/* 内容区:所有 pane 常驻堆叠(v-show 切换);滚动交给当前显示的 pane */
/* 下块内容卡:所有 pane 常驻堆叠(v-show 切换),滚动交给当前显示的 pane。
* overflow: hidden 让圆角裁住里面的内容区。 */
.mm-config-stack {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
padding: 16px;
overflow: hidden;
background: var(--g3-color-bg-container, #fff);
border-radius: var(--g3-radius-base, 0.5rem);
}
/* 面板头(右下内容卡的左上角):标题 + 说明在左,该面板自己的操作在右。
* 与标签栏分工不同 —— 标签栏只回答「在哪一页」,面板头回答「这一页是干什么的」。 */
.mm-panel-head {
display: flex;
align-items: flex-start;
gap: 12px;
flex: none;
margin-bottom: 16px;
}
.mm-panel-head__text {
min-width: 0;
}
.mm-panel-head__title {
color: var(--g3-color-text, #1f2329);
font-size: 15px;
font-weight: 600;
}
.mm-panel-head__desc {
margin-top: 4px;
color: var(--g3-color-text-tertiary, #86909c);
font-size: 12px;
}
/* 面板级操作:靠右且不参与压缩 */
.mm-panel-head__actions {
display: flex;
align-items: center;
gap: 8px;
flex: none;
margin-left: auto;
}
.mm-config-pane {
@@ -49,11 +49,48 @@ export type ModuleNode = TreeRow & {
b_bz: string | null
}
/** s_field 行的最小投影:基础信息面板「数据范围字段」候选只需要这三个字段 */
/**
* s_field 行的最小投影:基础信息面板「数据范围字段」候选只需要名称,
* 自动编码面板还要按业务类型 / 启用态过滤「业务编号字段」候选。
*/
export interface ModuleFieldRow {
b_field?: string | null
b_name?: string | null
b_i18n?: string | null
/** 业务类型(input / textarea / number / money / date / datetime / checkbox / select) */
b_type?: string | null
/** 启用态(1 启用 / 0 停用) */
b_canuse?: number | null
[key: string]: unknown
}
/**
* s_autocode 行:自动编码规则,主键 b_module_id + b_field(一个模块至多一条规则)。
* b_currentperiod / b_currentvalue 是运行时流水状态,由服务端维护,配置侧只读。
*/
export interface ModuleAutoCodeRow {
b_module_id: string
/** 业务编号字段(编号写入哪个字段) */
b_field: string | null
/** 编码前缀 */
b_prefix: string | null
/** 日期格式段(yyyyMM 等),空串表示不使用日期 */
b_dateformat: string | null
/** 各段之间的分隔符 */
b_separator: string | null
/** 流水号位数(左补 0) */
b_seqwidth: number
/** 重置周期:none 不重置 / year 每年 / month 每月 / day 每日 */
b_resettype: string
/** 每周期起始流水值 */
b_startvalue: number
/** 当前周期标识(运行时状态) */
b_currentperiod: string | null
/** 当前周期内已用到的流水值(运行时状态) */
b_currentvalue: number
/** 是否启用(1 / 0) */
b_canuse: number
/** 索引签名:G3Form 的 model 契约是 Record<string, unknown>,按 FormItem 的 name 取值 */
[key: string]: unknown
}
@@ -0,0 +1,64 @@
/**
* 侧栏菜单项:折叠态只显示图标。
*
* 回归背景:分组分支在折叠态走的是 G3Dropdown(触发器里只有图标),
* 而叶子分支的名称没跟着折叠态隐藏 —— 折叠后行里仍是「图标 + 名称」,
* 侧栏只剩 collapseWidth 那么宽(图标+间距就占掉二十几像素),
* 名称被裁成一两个字加省略号,比不显示更糟。
*/
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import SidebarMenuItem from '@/layouts/SidebarMenuItem.vue'
import type { SidebarMenuNode } from '@/types/menu'
const LEAF: SidebarMenuNode = {
key: 'approval_todo',
title: '我的待办',
path: '/approval/todo',
icon: { template: '<i class="stub-icon" />' },
}
function mountLeaf(collapsed: boolean) {
return mount(SidebarMenuItem, {
props: { item: LEAF, level: 0 },
global: {
plugins: [
createRouter({
history: createMemoryHistory(),
routes: [{ path: '/approval/todo', component: { template: '<div />' } }],
}),
],
provide: {
'sidebar-menu': {
collapsed: ref(collapsed),
isLeafActive: () => false,
isGroupActive: () => false,
isGroupOpen: () => false,
toggleGroup: () => {},
selectPath: () => {},
},
},
},
})
}
describe('SidebarMenuItem 叶子项', () => {
it('展开态:图标与名称都在,不需要 title 提示', () => {
const wrapper = mountLeaf(false)
expect(wrapper.find('.stub-icon').exists()).toBe(true)
expect(wrapper.find('.layout-sidebar__label').text()).toBe('我的待办')
expect(wrapper.find('a').attributes('title')).toBeUndefined()
})
it('折叠态:只渲染图标,名称不进 DOM,改为 title 提示', () => {
const wrapper = mountLeaf(true)
expect(wrapper.find('.stub-icon').exists()).toBe(true)
expect(wrapper.find('.layout-sidebar__label').exists()).toBe(false)
expect(wrapper.find('a').attributes('title')).toBe('我的待办')
})
})
@@ -0,0 +1,131 @@
/**
* 自动编码面板(s_autocode):惰性建行 + 实时预览 + 校验。
*
* 关键点:模块从未配置自动编码时草稿保持空数组(打开面板即建行会把「无变更」
* diff 成一条 insert),只有用户首次操作才落行;且 defineModel 写入后不能立刻
* 回读 prop(要下一轮才回传),所以建行后返回的是 reactive 代理行。
*/
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { G3Switch } from '@g3soft/ui'
import ModuleAutoCodePanel from '@/views/module/module-management/ModuleAutoCodePanel.vue'
import type { ModuleAutoCodeRow } from '@/views/module/module-management/utils'
const FIELDS = [
{ b_field: 'b_id', b_name: '编码', b_type: 'input', b_canuse: 1 },
{ b_field: 'b_no', b_name: '业务单号', b_type: 'input', b_canuse: 1 },
{ b_field: 'b_bz', b_name: '备注', b_type: 'textarea', b_canuse: 1 },
{ b_field: 'b_amount', b_name: '金额', b_type: 'money', b_canuse: 1 },
{ b_field: 'b_off', b_name: '停用字段', b_type: 'input', b_canuse: 0 },
]
/** 造一行规则:只写关心的字段,其余取启用态的常用默认值 */
function row(overrides: Partial<ModuleAutoCodeRow> = {}): ModuleAutoCodeRow {
return {
b_module_id: 'm1',
b_field: 'b_no',
b_prefix: '',
b_dateformat: 'yyyyMM',
b_separator: '-',
b_seqwidth: 5,
b_resettype: 'month',
b_startvalue: 1,
b_currentperiod: null,
b_currentvalue: 0,
b_canuse: 1,
...overrides,
}
}
/** 模拟父级 v-model:autocode:emit 回来要显式写回 prop,否则复现不了「立刻回读旧值」的问题 */
function mountPanel(autocode: ModuleAutoCodeRow[] = []) {
const wrapper = mount(ModuleAutoCodePanel, {
props: {
moduleId: 'm1',
fields: FIELDS,
autocode,
'onUpdate:autocode': (value: ModuleAutoCodeRow[]) => wrapper.setProps({ autocode: value }),
},
})
return wrapper
}
describe('自动编码面板', () => {
it('首次拨「启用」时惰性建行并写回 b_canuse(defineModel 不能写完立刻回读)', async () => {
const wrapper = mountPanel()
// 模块从未配置自动编码:草稿为空,此时拨开关会走 ensureRow 建行
wrapper.findComponent(G3Switch).vm.$emit('update:modelValue', true)
await wrapper.vm.$nextTick()
const emitted = wrapper.emitted('update:autocode')
expect(emitted).toBeTruthy()
const created = emitted![0][0] as ModuleAutoCodeRow[]
expect(created[0].b_canuse).toBe(1)
expect(created[0].b_module_id).toBe('m1')
// 业务编号字段自动取第一个可用文本字段(排除主键 b_id、非文本类型与停用字段)
expect(created[0].b_field).toBe('b_no')
})
it('已有一行时就地改,不再新建也不重新 emit 整个数组', async () => {
// 传进去的数组即父级持有的草稿:断言它被就地改写
const draft = [row({ b_canuse: 0, b_prefix: 'A' })]
const wrapper = mountPanel(draft)
wrapper.findComponent(G3Switch).vm.$emit('update:modelValue', true)
await wrapper.vm.$nextTick()
// 已落草稿:直接改这一行的属性(父级 ref 是深度响应式,改得见)
expect(wrapper.emitted('update:autocode')).toBeUndefined()
expect(draft[0]?.b_canuse).toBe(1)
expect(draft[0]?.b_prefix).toBe('A') // 沿用原行,未被占位行覆盖
})
it('紧凑网格固定竖排且标签左对齐,不跟随全局横排配置', () => {
const wrapper = mountPanel()
expect(wrapper.find('form').classes()).toContain('g3-form--vertical')
// 标签左对齐依赖 G3Form 的默认 labelAlign=left;竖排下若回到 right 会被推到整列右边缘
expect(wrapper.findAll('.g3-form-item__label.is-label-right')).toHaveLength(0)
})
it('预览按 前缀 + 日期 + 补位流水号 拼接', async () => {
const wrapper = mountPanel([
row({ b_prefix: 'G3', b_dateformat: '', b_seqwidth: 4, b_resettype: 'none' }),
])
await wrapper.vm.$nextTick()
const preview = wrapper.find('[data-field="b_preview"] input')
expect(preview.attributes('readonly')).toBeDefined()
expect((preview.element as HTMLInputElement).value).toBe('G3-0001')
})
it('启用态下的合法规则校验通过', async () => {
const wrapper = mountPanel([row()])
expect(await wrapper.vm.validate()).toEqual([])
})
it('启用但未选业务编号字段时报错(错误挂在对应 FormItem 上)', async () => {
const wrapper = mountPanel([row({ b_field: null })])
const errors = await wrapper.vm.validate()
expect(errors.map((item) => item.name)).toContain('b_field')
expect(errors.find((item) => item.name === 'b_field')?.errors[0]).toBe('请选择业务编号字段')
})
it('位数超出 1-18 时报错', async () => {
const wrapper = mountPanel([row({ b_seqwidth: 20 })])
const errors = await wrapper.vm.validate()
expect(errors.find((item) => item.name === 'b_seqwidth')?.errors[0]).toBe(
'流水号位数必须是 1 到 18 之间的整数',
)
})
it('未启用时不校验(占位行只用于展示)', async () => {
const wrapper = mountPanel()
expect(await wrapper.vm.validate()).toEqual([])
})
})