u
This commit is contained in:
1 parent
bf9dca19de
commit
e7c84a82a2
31 files changed
+4063
-2362
No files matched your search
+1
-1
@@ -30,7 +30,7 @@ public class IpRegionSearcher {
|
||||
LongByteArray buffer = new LongByteArray();
|
||||
buffer.append(bytes);
|
||||
searcher = Searcher.newWithBuffer(Version.IPv4, buffer);
|
||||
log.info("ip2region Searcher 初始化完成,数据大小 {} 字节", bytes.length);
|
||||
log.info("ip2region Searcher 初始化完成,数据大小 {} 字节", bytes.length);
|
||||
} catch (IOException e) {
|
||||
log.error("未找到 classpath 下的 ip2region.xdb,IP 归属地将返回「未知」", e);
|
||||
searcher = null;
|
||||
|
||||
@@ -27,6 +27,133 @@ export function typeOfAction(action: string): 'module' | 'action' {
|
||||
return isStandardModuleAction(action) ? 'module' : 'action'
|
||||
}
|
||||
|
||||
/**
|
||||
* 动作 → 默认权限名称(新增权限点时的兜底名称,不覆盖人工输入)。
|
||||
* 标准动作与业务动作都给中文名,未登记的动作回退动作编码本身。
|
||||
*
|
||||
* 这套动词是**跨模块公用**的:「通过」就是「通过」,与它挂在哪个模块下无关。
|
||||
* 因此权限库(历史动作去重列表)的名称也按这里生成 —— 库里若直接用 s_power.b_name,
|
||||
* 拿到的会是「模块名 - 动作名」(写入侧的口径,见 12_fms_workflow_power.sql),
|
||||
* 在按动作去重的库表里显示模块名是噪音。
|
||||
*/
|
||||
export const ACTION_NAMES: Record<string, string> = {
|
||||
[MODULE_ACCESS_ACTION]: '模块使用',
|
||||
// 标准操作
|
||||
read: '查看',
|
||||
create: '新增',
|
||||
update: '修改',
|
||||
delete: '删除',
|
||||
export: '导出',
|
||||
// 业务动作(工作流审批)
|
||||
submit: '送审',
|
||||
approve: '通过',
|
||||
reject: '驳回',
|
||||
return: '退回',
|
||||
withdraw: '撤回',
|
||||
transfer: '转交',
|
||||
delegate: '委托',
|
||||
reassign: '转派',
|
||||
add_approver: '加签',
|
||||
urge: '催办',
|
||||
workflow_admin: '流程管理员',
|
||||
// 通用业务动作
|
||||
audit: '审核',
|
||||
unaudit: '反审',
|
||||
}
|
||||
|
||||
/**
|
||||
* 标准动作的共用动作动词资源键(按钮 + 权限共用)。
|
||||
* 「新增」与权限名称共用 action.insert(SQL 语义);未登记动作回退 action.{动作}。
|
||||
*/
|
||||
export const ACTION_I18N_KEYS: Record<string, string> = {
|
||||
read: 'action.read',
|
||||
create: 'action.insert',
|
||||
update: 'action.update',
|
||||
delete: 'action.delete',
|
||||
export: 'action.export',
|
||||
audit: 'action.audit',
|
||||
unaudit: 'action.unaudit',
|
||||
}
|
||||
|
||||
/**
|
||||
* 动作码 → 展示名:优先取登记的中文动词,未登记的回落动作码本身。
|
||||
* 这是「动作」这一层的唯一命名口径,权限库与新增权限都用它,不在调用方各写一份。
|
||||
*/
|
||||
export function actionLabelOf(action: string): string {
|
||||
const value = String(action || '').trim()
|
||||
if (!value) return ''
|
||||
return ACTION_NAMES[value] || value
|
||||
}
|
||||
|
||||
/** 动作 → 共用动作动词资源键:通用词不分模块,同一动词只保存一份翻译 */
|
||||
export function buildActionI18nKey(action: string): string | null {
|
||||
const value = String(action || '').trim()
|
||||
if (!value) return null
|
||||
return ACTION_I18N_KEYS[value] || `action.${value}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限编码派生:模块使用权限为 module.{模块编码}(不带动作后缀),业务动作为
|
||||
* action.{模块编码}.{动作};动作或模块编码为空时返回 ''(行还没填完,保存时跳过)。
|
||||
*
|
||||
* 维护界面(模块管理 → 权限)的单元格编辑器与保存前物化共用,不在各自目录重写。
|
||||
*/
|
||||
export function buildPowerCode(action: string, moduleId: string): string {
|
||||
const normalizedAction = String(action || '').trim()
|
||||
const objectId = String(moduleId || '').trim()
|
||||
if (!normalizedAction || !objectId) return ''
|
||||
return isStandardModuleAction(normalizedAction)
|
||||
? (buildModulePowerCode(objectId) ?? '')
|
||||
: (buildActionPowerCode(objectId, normalizedAction) ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限点引用的多语言键:模块使用权限复用模块名称键 module.{模块编码}
|
||||
* (它显示的就是模块名);业务动作复用共用动词键 action.{动词}(跨模块公用,
|
||||
* 同一动词只维护一份翻译)。
|
||||
*/
|
||||
export function buildPowerI18nKey(action: string, moduleCode: string): string | null {
|
||||
const value = String(action || '').trim()
|
||||
if (!value) return null
|
||||
return typeOfAction(value) === 'module'
|
||||
? buildModuleI18nKey(moduleCode)
|
||||
: buildActionI18nKey(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限库(历史录入的动作)只读查询:跨模块去重,列出全系统用过的业务动作,
|
||||
* 供「模块管理 → 权限」一键导入。
|
||||
*
|
||||
* 标准动作(access / read / create / update / delete / export)不进库:它们的权限编码是
|
||||
* module.{模块编码}(整模块一个使用权限点),导入到某个模块会与该模块的模块使用权限撞编码;
|
||||
* 只有业务动作按 action.{模块编码}.{动作} 派生,才能跨模块复用。
|
||||
*
|
||||
* **只取 b_action,不取 b_name**:s_power.b_name 由写入侧拼成「模块名 - 动作名」
|
||||
* (见 12_fms_workflow_power.sql),而本表是**按动作跨模块去重**的,
|
||||
* 同一个动作在多个模块下会得到多个不同的名字,「取哪一个」没有意义 ——
|
||||
* 显示出来就是某个模块的名称(用户看到的「流程定义 - 通过」正是这么来的)。
|
||||
* 动作是跨模块公用的动词,名称统一由 actionLabelOf(action) 在展示侧生成。
|
||||
*/
|
||||
export function buildPowerLibrarySql(): string {
|
||||
const excluded = [MODULE_ACCESS_ACTION, ...STANDARD_MODULE_ACTIONS]
|
||||
.map((action) => `'${action}'`)
|
||||
.join(', ')
|
||||
return (
|
||||
'select distinct [b_action] ' +
|
||||
'from [s_power] ' +
|
||||
"where [b_object_type] = 'module' and [b_canuse] = 1 " +
|
||||
"and [b_action] is not null and [b_action] <> '' " +
|
||||
`and [b_action] not in (${excluded}) ` +
|
||||
'order by [b_action] asc'
|
||||
)
|
||||
}
|
||||
|
||||
/** 模块多语言资源键:module.{模块编码} */
|
||||
export function buildModuleI18nKey(moduleCode: string): string | null {
|
||||
const code = String(moduleCode || '').trim()
|
||||
return code ? `module.${code}` : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单入口权限编码:menu.{菜单编码}。
|
||||
* @returns 权限编码;编码为空时返回 null
|
||||
|
||||
@@ -37,15 +37,11 @@ export function createDefaultSchema(schemaType: string): SchemaJson {
|
||||
// (v1 是平铺字段节点 + newline 标记,加载时仍可容错折成行)
|
||||
if (schemaType === 'edit') return { schemaVersion: 2, children: [] }
|
||||
if (schemaType === 'query') {
|
||||
// schemaVersion 3:页签可分组(组内单选、组间叠加),页签条件直写 SQL,
|
||||
// 更多筛选由 queryFields 自动铺开(不再手工分组);
|
||||
// 关键词只配可搜索字段,占位文案与触发方式运行时定
|
||||
// schemaVersion 4:只保留工作队列页签(组内单选、组间叠加,条件直写 SQL)。
|
||||
// 查询能力(关键词字段、更多筛选字段、控件与算子)由列表页按 s_field 运行时推导,不再配置。
|
||||
return {
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
views: [],
|
||||
keywordSearch: { fields: [] },
|
||||
quickFilters: [],
|
||||
queryFields: {},
|
||||
}
|
||||
}
|
||||
return { schemaVersion: 1 }
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 多语言面板(s_i18n):受控展示当前模块的多语言译文。
|
||||
*
|
||||
* 数据契约:v-model:rows 绑定父级持有的**透视草稿行**(一行 = 一个资源键,
|
||||
* 每个语言一列)。父级负责语言清单的加载与保存基线,本面板只做展示与行内编辑。
|
||||
*
|
||||
* 透视行的形状:{ _rowKey, b_key, [语言编码]: 译文 }。
|
||||
* s_i18n 的落库形态是长表(b_key + b_locale),透视只是编辑期的视图 ——
|
||||
* 转换与 diff 在父级编排,本面板不碰落库结构。
|
||||
*
|
||||
* 「受管行」:模块名 / 字段名 / 分组标题这类由系统派生的键(_managed),
|
||||
* 键名不可改、行不可删 —— 它们跟着模块结构走,手工删掉下次同步又会回来。
|
||||
* 默认语言列在这类行上只读(值就是名称本身),改文案要去改名称。
|
||||
*
|
||||
* 保存编排尚未移植,本步只做表格与编辑交互。
|
||||
*/
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
G3Button,
|
||||
G3Table,
|
||||
type G3TableColumn,
|
||||
type TableRow,
|
||||
} from '@g3soft/ui'
|
||||
import { createTempId } from '@/utils/common'
|
||||
|
||||
/** 语言项(s_i18n_type 行) */
|
||||
export interface I18nLanguage {
|
||||
/** 语言编码(s_i18n_type.b_id,如 zh-CN / en) */
|
||||
code: string
|
||||
/** 语言名称 */
|
||||
name: string
|
||||
/** 是否默认语言(s_i18n_type.b_default = 1) */
|
||||
default?: boolean
|
||||
}
|
||||
|
||||
/** 透视草稿行 */
|
||||
export type I18nPivotRow = TableRow & {
|
||||
_rowKey: string
|
||||
b_key?: string
|
||||
/** 受管行(键由系统派生):键名只读、行不可删 */
|
||||
_managed?: boolean
|
||||
/** 默认语言列只读(值由名称决定,改文案要去改名称本身) */
|
||||
_autoDefault?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 语言清单(来自 s_i18n_type),决定列定义与默认语言列 */
|
||||
languages?: I18nLanguage[]
|
||||
/** 父级忙碌态(加载中):期间锁掉新增与批量删除 */
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{ languages: () => [], loading: false },
|
||||
)
|
||||
|
||||
const rowsModel = defineModel<I18nPivotRow[]>('rows', { default: () => [] })
|
||||
|
||||
/** 默认语言编码:b_default = 1 的那一个 */
|
||||
const defaultLocale = computed(
|
||||
() => props.languages.find((language) => language.default === true)?.code ?? '',
|
||||
)
|
||||
|
||||
/** 受管行不可改键名、不可删除 */
|
||||
function isManagedRow(row: TableRow): boolean {
|
||||
return Boolean((row as I18nPivotRow)._managed)
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认语言值由名称决定的受管行(模块名 / 字段名 / 分组标题):
|
||||
* 这些行的默认语言列只读 —— 值由父级的同步逻辑按当前名称维护,改文案要去改名称本身,
|
||||
* 否则会留下一条和名称对不上的译文(列表、权限面板都优先取译文)。
|
||||
*/
|
||||
function isNameDrivenRow(row: TableRow): boolean {
|
||||
return Boolean((row as I18nPivotRow)._autoDefault)
|
||||
}
|
||||
|
||||
const tableRef = ref<InstanceType<typeof G3Table> | null>(null)
|
||||
const activeKey = ref<string | null>(null)
|
||||
const selectedRowKeys = ref<string[]>([])
|
||||
|
||||
/** 新增一行空键,并直接进键名单元格编辑 */
|
||||
async function addRow() {
|
||||
if (props.loading) return
|
||||
const newKey = createTempId()
|
||||
rowsModel.value = [...rowsModel.value, { _rowKey: newKey, b_key: '' }]
|
||||
activeKey.value = String(newKey)
|
||||
await nextTick()
|
||||
tableRef.value?.editRow?.(String(newKey), 'b_key')
|
||||
}
|
||||
|
||||
function removeRow(row: TableRow) {
|
||||
if (isManagedRow(row)) return
|
||||
const key = String((row as I18nPivotRow)._rowKey ?? '')
|
||||
rowsModel.value = rowsModel.value.filter((item) => item !== row)
|
||||
selectedRowKeys.value = selectedRowKeys.value.filter((item) => item !== key)
|
||||
}
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
// 受管行不给勾:它们删不掉,勾了只会让「批量删除」的计数对不上
|
||||
getCheckboxProps: (row: TableRow) => ({ disabled: isManagedRow(row) }),
|
||||
onChange: (keys: unknown[]) => {
|
||||
selectedRowKeys.value = (keys || []).map(String)
|
||||
},
|
||||
}))
|
||||
|
||||
function batchDelete() {
|
||||
if (props.loading || !selectedRowKeys.value.length) return
|
||||
const removing = new Set(selectedRowKeys.value)
|
||||
const deletable = rowsModel.value.filter(
|
||||
(row) => removing.has(String(row._rowKey)) && !isManagedRow(row),
|
||||
)
|
||||
if (!deletable.length) return
|
||||
const delKeys = new Set(deletable.map((row) => String(row._rowKey)))
|
||||
rowsModel.value = rowsModel.value.filter((row) => !delKeys.has(String(row._rowKey)))
|
||||
selectedRowKeys.value = []
|
||||
if (activeKey.value && delKeys.has(String(activeKey.value))) activeKey.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 列定义:多语言键 + 每个语言一列 + 操作。
|
||||
* 用 computed 而不是 shallowRef —— 语言清单是异步到的(s_i18n_type 加载完才传进来),
|
||||
* 列必须跟着重算;本表格不需要用户拖列宽回写(透视表列由语言决定,不该让用户改没)。
|
||||
*/
|
||||
const columns = computed<G3TableColumn<TableRow>[]>(() => {
|
||||
const cols: G3TableColumn<TableRow>[] = [
|
||||
{
|
||||
dataIndex: 'b_key',
|
||||
title: '多语言键',
|
||||
width: 260,
|
||||
editor: { type: 'text', disabled: (row: TableRow) => isManagedRow(row) },
|
||||
},
|
||||
]
|
||||
for (const language of props.languages) {
|
||||
const isDefault = language.code === defaultLocale.value
|
||||
cols.push({
|
||||
dataIndex: language.code,
|
||||
title: language.name,
|
||||
width: 200,
|
||||
editor: {
|
||||
type: 'text',
|
||||
disabled: (row: TableRow) => isDefault && isNameDrivenRow(row),
|
||||
},
|
||||
})
|
||||
}
|
||||
cols.push({
|
||||
dataIndex: 'actions',
|
||||
title: '',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
actions: [
|
||||
{
|
||||
type: 'delete',
|
||||
label: '删除',
|
||||
disabled: (row: TableRow) => isManagedRow(row),
|
||||
onClick: (row: TableRow) => removeRow(row),
|
||||
},
|
||||
],
|
||||
})
|
||||
return cols
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-i18n">
|
||||
<div class="mm-i18n__toolbar">
|
||||
<G3Button type="primary" :disabled="loading" @click="addRow">
|
||||
<template #icon><Plus :size="14" /></template>
|
||||
新增多语言
|
||||
</G3Button>
|
||||
<G3Button
|
||||
type="danger-outline"
|
||||
:disabled="loading || !selectedRowKeys.length"
|
||||
@click="batchDelete"
|
||||
>
|
||||
<template #icon><Trash2 :size="14" /></template>
|
||||
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</G3Button>
|
||||
</div>
|
||||
<div class="mm-i18n__table">
|
||||
<G3Table
|
||||
ref="tableRef"
|
||||
v-model:columns="columns"
|
||||
:data-source="rowsModel"
|
||||
:row-height="28"
|
||||
row-key="_rowKey"
|
||||
:row-selection="rowSelection"
|
||||
:active-row-key="activeKey"
|
||||
@row-click="(_, row) => (activeKey = String((row as I18nPivotRow)._rowKey ?? ''))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mm-i18n {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.mm-i18n__toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
gap: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.mm-i18n__table {
|
||||
/* 纵向撑满剩余高度(否则表体滚动区失效);横向不拉伸 ——
|
||||
这一层被拉满后,表格的 `max-width: 100%` 就会以「被拉伸的宽度」为上限,
|
||||
等于变相限宽、拖列宽照样出滚动条(权限面板踩过同一个坑)。 */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 表格按列宽定宽,不铺满容器 —— 与权限面板同一口径。
|
||||
*
|
||||
* 多语言表的列数由系统语言决定(通常 1~3 个语言 + 键 + 操作),拉满容器只会
|
||||
* 在每个语言列之间摊开一片空白。让表框贴住内容宽度即可;语言特别多、列宽之和
|
||||
* 超过容器时由 max-width 退回容器宽,横向滚动交给表格自己。
|
||||
*
|
||||
* fit-content 必须作用在**表框**(G3Table 根节点 .g3-table)上:只定宽内层
|
||||
* <table> 的话外框仍会拉满,看起来还是铺满的。 */
|
||||
.mm-i18n__table :deep(.g3-table) {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,564 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 模块权限面板(s_power):维护当前模块的权限点。
|
||||
*
|
||||
* 口径(与 utils/powerCodes 同源,不在本目录另立一套):
|
||||
* - 权限点挂在模块上:b_object_type='module'、b_object_id=当前模块编码;
|
||||
* - 模块使用权限整模块一个点:b_action='access',编码 module.{模块编码}(不带动作后缀);
|
||||
* 标准操作 read / create / update / delete / export 不再各自拆分权限点;
|
||||
* - 业务动作(审核 / 反审 / …):b_type='action',编码 action.{模块编码}.{动作};
|
||||
* - b_type 由动作派生、不手工选择;权限编码与多语言键均派生、不手工编辑。
|
||||
*
|
||||
* 新增走「加一行空动作行 → 直接进动作单元格编辑」:动作决定权限编码,必须先填。
|
||||
* 删除分两种:行内删除(单个)与选中后批量删除。
|
||||
*
|
||||
* 右侧「权限库」是全系统用过的业务动作去重列表,勾选后一键导入到本模块,
|
||||
* 不必再手打动作名;本模块已有的动作在库里置灰,标准动作不进库
|
||||
* (其编码是 module.{模块编码},导入会与模块使用权限撞编码)。
|
||||
*
|
||||
* 数据契约:v-model:powers 绑定父级持有的权限点草稿(就地编辑、不回写库)。
|
||||
* 保存编排(逐表 diff → 落库)尚未移植,本步只做表格与编辑交互。
|
||||
*/
|
||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { ArrowLeft, Plus, RefreshCw, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
G3Button,
|
||||
G3Message,
|
||||
G3Table,
|
||||
type G3TableColumn,
|
||||
type TableRow,
|
||||
} from '@g3soft/ui'
|
||||
import { loadDataBySqlApi } from '@/services/api'
|
||||
import { createTempId } from '@/utils/common'
|
||||
import {
|
||||
MODULE_ACCESS_ACTION,
|
||||
actionLabelOf,
|
||||
buildPowerCode,
|
||||
buildPowerI18nKey,
|
||||
buildPowerLibrarySql,
|
||||
typeOfAction,
|
||||
} from '@/utils/powerCodes'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
/** 当前模块编码:权限点的挂载对象(b_object_id) */
|
||||
moduleId?: string
|
||||
/** 当前模块名称:模块使用权限点的默认名称 */
|
||||
moduleName?: string
|
||||
/** 父级忙碌态(加载中):期间锁掉新增与批量删除 */
|
||||
loading?: boolean
|
||||
}>(),
|
||||
{ moduleId: '', moduleName: '', loading: false },
|
||||
)
|
||||
|
||||
/** 权限点行(s_power),父级 v-model 绑定,就地编辑 */
|
||||
const powersModel = defineModel<TableRow[]>('powers', { default: () => [] })
|
||||
|
||||
/** 权限点行(含本地行键 _rowKey,表格行身份用) */
|
||||
type PowerRow = TableRow & {
|
||||
_rowKey: string
|
||||
b_id?: string
|
||||
b_name?: string
|
||||
b_action?: string
|
||||
b_canuse?: number
|
||||
b_i18n?: string
|
||||
b_type?: string
|
||||
b_object_type?: string
|
||||
b_object_id?: string
|
||||
b_xh?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载阶段只补本地行键(表格的行身份):b_object_type / b_object_id / b_xh / b_type / b_id
|
||||
* 都是派生列,**不在加载时物化** —— 库里这些派生列与当前规则不一致时(历史数据把 b_id
|
||||
* 存成 module.{编码}.access、b_xh 与排序规则不同等),物化会把草稿改脏,
|
||||
* 表现为「打开模块即提示未保存」。派生列统一在用户编辑时物化。
|
||||
*/
|
||||
function ensureRowKeys(rows: TableRow[]): boolean {
|
||||
let changed = false
|
||||
for (const row of rows || []) {
|
||||
if (!(row as PowerRow)._rowKey) {
|
||||
;(row as PowerRow)._rowKey = createTempId()
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
watch(
|
||||
() => powersModel.value,
|
||||
(rows) => {
|
||||
if (ensureRowKeys(rows)) powersModel.value = [...rows]
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
)
|
||||
|
||||
/** 表格给回来的是**展示副本**,按 _rowKey 找回草稿里的那一行 */
|
||||
function draftRowOf(row: TableRow): PowerRow | undefined {
|
||||
const key = String((row as PowerRow)._rowKey ?? '')
|
||||
if (!key) return undefined
|
||||
return powersModel.value.find((item) => String((item as PowerRow)._rowKey) === key) as
|
||||
| PowerRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 单元格变更:写回**草稿**(表格数据是展示副本,不能就地改它 —— 改了下一帧就被
|
||||
* displayRows 重新算出来覆盖掉,表现为「编辑没反应」)。
|
||||
*
|
||||
* 动作列还要按共用口径重算编码 / 性质 / 多语言键。名称留空时补默认名:
|
||||
* 模块使用权限取模块名,其余取共用动词名。
|
||||
*/
|
||||
function onCellChange(row: TableRow, col: { dataIndex?: string }, value?: unknown) {
|
||||
const target = draftRowOf(row)
|
||||
const field = col?.dataIndex
|
||||
if (!target || !field) return
|
||||
|
||||
target[field] = value === '' ? null : value
|
||||
|
||||
if (field === 'b_action') {
|
||||
const action = String(target.b_action ?? '')
|
||||
if (!String(target.b_name ?? '').trim()) {
|
||||
target.b_name =
|
||||
action === MODULE_ACCESS_ACTION
|
||||
? props.moduleName || '模块使用'
|
||||
: actionLabelOf(action)
|
||||
}
|
||||
target.b_type = typeOfAction(action)
|
||||
target.b_id = buildPowerCode(action, props.moduleId)
|
||||
const i18nKey = buildPowerI18nKey(action, props.moduleId)
|
||||
if (i18nKey) target.b_i18n = i18nKey
|
||||
}
|
||||
normalizeRows()
|
||||
}
|
||||
|
||||
/**
|
||||
* 物化派生列:补行键、回填挂载对象与显示顺序、派生类型与编码,并按编码去重
|
||||
* (重复的只保留第一条,其余清空编码)。
|
||||
*
|
||||
* 只在**用户编辑**时调用:加载阶段不物化,避免库里派生值与规则不一致时「打开即变脏」。
|
||||
*/
|
||||
function normalizeRows() {
|
||||
const objectId = String(props.moduleId || '')
|
||||
powersModel.value.forEach((row, index) => {
|
||||
const target = row as PowerRow
|
||||
target.b_object_type = 'module'
|
||||
target.b_object_id = objectId
|
||||
target.b_xh = (index + 1) * 10
|
||||
target.b_type = typeOfAction(String(target.b_action ?? ''))
|
||||
})
|
||||
const seen = new Set<string>()
|
||||
for (const row of powersModel.value) {
|
||||
const target = row as PowerRow
|
||||
const code = String(target.b_id ?? '').trim()
|
||||
if (!code) continue
|
||||
if (seen.has(code)) {
|
||||
target.b_id = ''
|
||||
continue
|
||||
}
|
||||
seen.add(code)
|
||||
}
|
||||
// 行对象已就地 patch,换新数组引用触发表格重渲染
|
||||
powersModel.value = [...powersModel.value]
|
||||
}
|
||||
|
||||
/**
|
||||
* 造一行权限点:派生列一次算好。
|
||||
* @param action 动作码;@param name 指定名称(导入权限库时沿用库里的动词名)
|
||||
*/
|
||||
function makeRow(action: string, name?: string): PowerRow {
|
||||
const isAccess = action === MODULE_ACCESS_ACTION
|
||||
const row: PowerRow = {
|
||||
_rowKey: createTempId(),
|
||||
b_object_type: 'module',
|
||||
b_object_id: String(props.moduleId || ''),
|
||||
b_action: action,
|
||||
// 模块使用权限的名称取模块名(它就是「这个模块能不能用」);其余取共用动词名
|
||||
b_name: name || (isAccess ? props.moduleName || '模块使用' : actionLabelOf(action)),
|
||||
b_canuse: 1,
|
||||
}
|
||||
row.b_type = typeOfAction(action)
|
||||
row.b_id = buildPowerCode(action, props.moduleId)
|
||||
const i18nKey = buildPowerI18nKey(action, props.moduleId)
|
||||
if (i18nKey) row.b_i18n = i18nKey
|
||||
return row
|
||||
}
|
||||
|
||||
/** 表格实例:新增后直接进动作单元格编辑 */
|
||||
const tableRef = ref<InstanceType<typeof G3Table> | null>(null)
|
||||
|
||||
/** 新增空动作行并直接进入动作单元格编辑 */
|
||||
async function addRow() {
|
||||
if (props.loading) return
|
||||
const row = makeRow('')
|
||||
powersModel.value = [...powersModel.value, row]
|
||||
await nextTick()
|
||||
tableRef.value?.editRow?.(String(row._rowKey), 'b_action')
|
||||
}
|
||||
|
||||
/**
|
||||
* 行内删除:按 _rowKey 从草稿里摘掉。
|
||||
* 不能用 `item !== row` —— 表格给回来的是展示副本,与草稿里的行不是同一个对象引用,
|
||||
* 用引用比较会一行都删不掉。
|
||||
*/
|
||||
function removeRow(row: TableRow) {
|
||||
const key = String((row as PowerRow)._rowKey ?? '')
|
||||
if (!key) return
|
||||
powersModel.value = powersModel.value.filter(
|
||||
(item) => String((item as PowerRow)._rowKey) !== key,
|
||||
)
|
||||
// 删掉的行若在选中集合里,同步摘掉 —— 否则「批量删除(n)」的计数会把已删行算进去
|
||||
if (selectedRowKeys.value.includes(key)) {
|
||||
selectedRowKeys.value = selectedRowKeys.value.filter((item) => item !== key)
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 批量删除(表格多选) ---------- */
|
||||
|
||||
const selectedRowKeys = ref<string[]>([])
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys: unknown[]) => {
|
||||
selectedRowKeys.value = (keys || []).map(String)
|
||||
},
|
||||
}))
|
||||
|
||||
/** 批量删除选中的权限点行,删完清空选中 */
|
||||
function removeSelectedRows() {
|
||||
if (!selectedRowKeys.value.length) return
|
||||
const removing = new Set(selectedRowKeys.value)
|
||||
powersModel.value = powersModel.value.filter(
|
||||
(row) => !removing.has(String((row as PowerRow)._rowKey)),
|
||||
)
|
||||
selectedRowKeys.value = []
|
||||
}
|
||||
|
||||
// 切换模块时清掉选中:面板实例跨模块复用,上一模块的选中行不该留着
|
||||
watch(
|
||||
() => props.moduleId,
|
||||
() => {
|
||||
selectedRowKeys.value = []
|
||||
},
|
||||
)
|
||||
|
||||
/* ---------- 权限库(历史录入的业务动作,右侧导入) ---------- */
|
||||
|
||||
/** 库行:跨模块去重后的业务动作 */
|
||||
interface LibraryRow extends TableRow {
|
||||
_rowKey: string
|
||||
b_action: string
|
||||
b_name: string
|
||||
}
|
||||
|
||||
const libraryRows = ref<LibraryRow[]>([])
|
||||
const librarySelectedKeys = ref<string[]>([])
|
||||
const libraryLoading = ref(false)
|
||||
/** 请求序号:切换模块 / 连点刷新时,只有最后一次的结果能落到界面上 */
|
||||
let libraryToken = 0
|
||||
|
||||
/** 本模块已用到的动作:库里对应的行置灰(导入会生成同编码的重复行) */
|
||||
const usedActions = computed(
|
||||
() =>
|
||||
new Set(
|
||||
powersModel.value.map((row) => String((row as PowerRow).b_action ?? '').trim()).filter(Boolean),
|
||||
),
|
||||
)
|
||||
|
||||
/** 库表列:名称固定一档窄宽,动作列自适应吃剩余宽度 */
|
||||
const libraryColumns = shallowRef<G3TableColumn<TableRow>[]>([
|
||||
{ dataIndex: 'b_name', title: '名称', width: 88 },
|
||||
{ dataIndex: 'b_action', title: '动作' },
|
||||
])
|
||||
|
||||
const libraryRowSelection = computed(() => ({
|
||||
selectedRowKeys: librarySelectedKeys.value,
|
||||
onChange: (keys: unknown[]) => {
|
||||
librarySelectedKeys.value = (keys || []).map(String)
|
||||
},
|
||||
// 已在本模块的动作不给勾:导入了也是重复编码
|
||||
getCheckboxProps: (row: TableRow) => ({
|
||||
disabled: usedActions.value.has(String((row as LibraryRow).b_action ?? '').trim()),
|
||||
}),
|
||||
}))
|
||||
|
||||
/** 读权限库:SQL 只回动作码(见 buildPowerLibrarySql),名称按共用动词口径生成 */
|
||||
async function loadLibrary() {
|
||||
const token = (libraryToken += 1)
|
||||
librarySelectedKeys.value = []
|
||||
libraryLoading.value = true
|
||||
try {
|
||||
const rows = await loadDataBySqlApi(buildPowerLibrarySql())
|
||||
if (token !== libraryToken) return
|
||||
libraryRows.value = (rows || [])
|
||||
.map((row) => {
|
||||
const action = String(row.b_action ?? '')
|
||||
return {
|
||||
_rowKey: `action:${action}`,
|
||||
b_action: action,
|
||||
// 名称取共用动词名(「通过」),**不用** s_power.b_name ——
|
||||
// 库里那是「模块名 - 动作名」,在按动作去重的库表里显示模块名是噪音
|
||||
b_name: actionLabelOf(action),
|
||||
}
|
||||
})
|
||||
.filter((row) => row.b_action)
|
||||
} catch {
|
||||
if (token === libraryToken) libraryRows.value = []
|
||||
} finally {
|
||||
if (token === libraryToken) libraryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换模块时重取:在别的模块里改过动作后,「切模块 → 回来导入」拿得到最新清单
|
||||
watch(
|
||||
() => props.moduleId,
|
||||
(id) => {
|
||||
librarySelectedKeys.value = []
|
||||
if (!id) {
|
||||
// 丢弃在途结果:没有当前模块,落回来只会覆盖下一次的状态
|
||||
libraryToken += 1
|
||||
return
|
||||
}
|
||||
void loadLibrary()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
/**
|
||||
* 导入选中的库行:往本模块权限点里追加动作行(名称沿用库里的),已存在的动作跳过。
|
||||
* 与「新增权限」同一条路径:派生列由 makeRow 统一物化。
|
||||
*/
|
||||
function importSelectedActions() {
|
||||
if (props.loading || !librarySelectedKeys.value.length) return
|
||||
const picked = libraryRows.value.filter((row) =>
|
||||
librarySelectedKeys.value.includes(String(row._rowKey)),
|
||||
)
|
||||
const existing = new Set(usedActions.value)
|
||||
const added: PowerRow[] = []
|
||||
for (const item of picked) {
|
||||
const action = String(item.b_action || '').trim()
|
||||
if (!action || existing.has(action)) continue
|
||||
existing.add(action)
|
||||
added.push(makeRow(action, item.b_name))
|
||||
}
|
||||
librarySelectedKeys.value = []
|
||||
if (!added.length) {
|
||||
G3Message.info('选中的动作本模块都已存在')
|
||||
return
|
||||
}
|
||||
powersModel.value = [...powersModel.value, ...added]
|
||||
normalizeRows()
|
||||
G3Message.success(`已导入 ${added.length} 个权限点`)
|
||||
}
|
||||
|
||||
/* ---------- 列定义 ---------- */
|
||||
|
||||
/**
|
||||
* 表格数据:权限名称只显示**动作名**(「通过」),不显示库里的「模块名 - 动作名」。
|
||||
*
|
||||
* 这是**展示副本**,不是草稿本身:
|
||||
* - 名称按动作口径渲染(模块使用权限除外 —— 它的名字就是模块名,取动作名没有意义);
|
||||
* - 编辑器的改动由 onCellChange 按 _rowKey 写回 powersModel,不写这份副本。
|
||||
*
|
||||
* 为什么不直接改草稿里的 b_name:它是**用户可编辑**的列,且库里存的是
|
||||
* 「模块名 - 动作名」(写入侧口径,见 12_fms_workflow_power.sql)。若加载时就把它
|
||||
* 替换成动作名,用户一进编辑态看到的便是不同的值,**一保存就把库里的值静默改写**。
|
||||
* 只改展示、不碰落库值,才不会动到别人的数据。
|
||||
*/
|
||||
const displayRows = computed<TableRow[]>(() =>
|
||||
powersModel.value.map((row) => {
|
||||
const target = row as PowerRow
|
||||
const action = String(target.b_action ?? '')
|
||||
// 模块使用权限的名称取模块名(它就是「这个模块能不能用」),不按动作名渲染
|
||||
if (action === MODULE_ACCESS_ACTION) return target
|
||||
return { ...target, b_name: actionLabelOf(action) }
|
||||
}),
|
||||
)
|
||||
|
||||
const columns = shallowRef<G3TableColumn<TableRow>[]>([
|
||||
{ dataIndex: 'b_id', title: '权限编码', width: 190 },
|
||||
{ dataIndex: 'b_name', title: '权限名称', width: 140, editor: { type: 'text' } },
|
||||
{ dataIndex: 'b_action', title: '动作', width: 110, editor: { type: 'text' } },
|
||||
{
|
||||
dataIndex: 'b_canuse',
|
||||
title: '启用',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{ dataIndex: 'b_i18n', title: '多语言键', width: 170 },
|
||||
{
|
||||
dataIndex: 'actions',
|
||||
title: '',
|
||||
width: 60,
|
||||
align: 'center',
|
||||
actions: [
|
||||
{
|
||||
type: 'delete',
|
||||
label: '删除',
|
||||
onClick: (row: TableRow) => removeRow(row),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mm-power">
|
||||
<!-- 左右两块并排:左本模块权限点(表按列宽占位),右权限库(定宽)。
|
||||
多余宽度留在最右侧,窄窗口下两块一起被压窄,各自内部滚动。 -->
|
||||
<div class="mm-power__row">
|
||||
<!-- 左:本模块权限点 -->
|
||||
<div class="mm-power__section mm-power__section--main">
|
||||
<div class="mm-power__toolbar">
|
||||
<G3Button type="primary" :disabled="loading" @click="addRow">
|
||||
<template #icon><Plus :size="14" /></template>
|
||||
新增权限
|
||||
</G3Button>
|
||||
<G3Button
|
||||
type="danger-outline"
|
||||
:disabled="loading || !selectedRowKeys.length"
|
||||
:title="
|
||||
selectedRowKeys.length
|
||||
? `删除选中的 ${selectedRowKeys.length} 个权限点`
|
||||
: '先勾选要删除的权限点'
|
||||
"
|
||||
@click="removeSelectedRows"
|
||||
>
|
||||
<template #icon><Trash2 :size="14" /></template>
|
||||
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</G3Button>
|
||||
</div>
|
||||
<div class="mm-power__table">
|
||||
<G3Table
|
||||
ref="tableRef"
|
||||
v-model:columns="columns"
|
||||
:data-source="displayRows"
|
||||
:row-height="28"
|
||||
row-key="_rowKey"
|
||||
:row-selection="rowSelection"
|
||||
@change="onCellChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:权限库(其它模块用过的业务动作),勾选后导入到左侧 -->
|
||||
<div class="mm-power__section mm-power__section--library">
|
||||
<div class="mm-power__toolbar">
|
||||
<span class="mm-power__caption">权限库(历史录入)</span>
|
||||
<G3Button
|
||||
type="outline"
|
||||
:disabled="loading || !librarySelectedKeys.length"
|
||||
title="把选中的动作导入到左侧(本模块已有的动作会跳过)"
|
||||
@click="importSelectedActions"
|
||||
>
|
||||
<template #icon><ArrowLeft :size="14" /></template>
|
||||
导入{{ librarySelectedKeys.length ? `(${librarySelectedKeys.length})` : '' }}
|
||||
</G3Button>
|
||||
<G3Button
|
||||
type="outline"
|
||||
title="重新读取权限库"
|
||||
aria-label="刷新权限库"
|
||||
@click="loadLibrary"
|
||||
>
|
||||
<template #icon><RefreshCw :size="14" /></template>
|
||||
</G3Button>
|
||||
</div>
|
||||
<div class="mm-power__table">
|
||||
<G3Table
|
||||
v-model:columns="libraryColumns"
|
||||
:data-source="libraryRows"
|
||||
:row-height="28"
|
||||
row-key="_rowKey"
|
||||
:row-selection="libraryRowSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mm-power {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 左右两块并排:两块紧挨着,多余宽度留在最右侧 */
|
||||
.mm-power__row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mm-power__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 左:只占表格自己的宽度,窗口窄时跟着缩、由表内横向滚动兜底 */
|
||||
.mm-power__section--main {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
|
||||
/* 右:权限库也只占自己内容宽 —— 库表就两列,拉宽只是多一片空白。
|
||||
**不设 max-width**:一旦限宽,它就成了列宽拖拽的天花板 ——
|
||||
列宽之和超过这个上限时表框被压住,拖到哪里都出横向滚动条(左表没有这个上限,
|
||||
所以拖同样的量它不出滚动条,两张表的手感会不一致)。 */
|
||||
.mm-power__section--library {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mm-power__toolbar {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* 库表自己的标题:长文案省略,不与右侧按钮争宽 */
|
||||
.mm-power__caption {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--g3-color-text-secondary, #4e5969);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mm-power__table {
|
||||
/* 纵向仍要撑满分栏剩余高度(否则表体区域塌成内容高、滚动区失效);
|
||||
横向**不能**用 flex 拉伸 —— 见下一条注释。 */
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
/* 这层是 inline 尺寸跟随内容的块:宽度由子元素(表格)撑开,不被父级拉满,
|
||||
这样表格的 `max-width: 100%` 才是「分栏宽度」这个真实上限,
|
||||
而不是「被拉伸后的宽度」。 */
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* 表格按列宽定宽,不铺满容器。
|
||||
*
|
||||
* G3Table 的根节点自带 `width: 100%`,所以默认一定会拉满父级 —— 而权限面板这里
|
||||
* 两张表都**不该**铺满:左表 5 列、右表 2 列,拉宽只是在各列之间摊开一片空白。
|
||||
* 让表框贴住内容宽度,多余宽度留在最右侧即可(照 fms-vue 的 ModulePowerPanel)。
|
||||
*
|
||||
* fit-content 作用在**表框**(G3Table 根节点)上:只定宽内层 <table> 的话外框
|
||||
* 仍会拉满,看起来还是铺满的。max-width 保证列宽之和超过分栏时退回分栏宽,
|
||||
* 横向滚动交给表格自己(这时拖列宽仍然可用)。 */
|
||||
.mm-power__table :deep(.g3-table) {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -25,7 +25,7 @@ import {
|
||||
G3Tabs,
|
||||
G3Tag,
|
||||
} from '@g3soft/ui'
|
||||
import type { DropdownClickPayload, TreeDropInfo, TreeKey } from '@g3soft/ui'
|
||||
import type { DropdownClickPayload, TableRow, TreeDropInfo, TreeKey } from '@g3soft/ui'
|
||||
import { loadDataApi, saveObjectApi } from '@/services/api'
|
||||
import { createTempId } from '@/utils/common'
|
||||
import { buildPowerDeleteDependents } from '@/utils/powerCodes'
|
||||
@@ -50,6 +50,8 @@ import {
|
||||
import ModuleAutoCodePanel from './ModuleAutoCodePanel.vue'
|
||||
import ModuleBasicPanel from './ModuleBasicPanel.vue'
|
||||
import ModuleFieldsPanel from './ModuleFieldsPanel.vue'
|
||||
import ModuleI18nPanel, { type I18nLanguage, type I18nPivotRow } from './ModuleI18nPanel.vue'
|
||||
import ModulePowerPanel from './ModulePowerPanel.vue'
|
||||
import ModuleQueryPanel from './ModuleQueryPanel.vue'
|
||||
import ModuleTreePanel from './ModuleTreePanel.vue'
|
||||
import {
|
||||
@@ -359,6 +361,40 @@ const moduleAutoCode = ref<ModuleAutoCodeRow[]>([])
|
||||
/** 选中模块的查询设置草稿(s_module_schema.b_schema_type = 'query' 的配置对象) */
|
||||
const moduleQuery = ref<SchemaJson>(createDefaultSchema('query'))
|
||||
|
||||
/** 选中模块的权限点草稿(s_power,b_object_type='module') */
|
||||
const modulePowers = ref<TableRow[]>([])
|
||||
|
||||
/** 选中模块的多语言译文透视草稿(s_i18n 长表 → 一行一个资源键) */
|
||||
const moduleI18nRows = ref<I18nPivotRow[]>([])
|
||||
|
||||
/**
|
||||
* 选中模块配置的加载态:权限 / 多语言这类表格面板在加载期间要锁掉增删,
|
||||
* 否则会往一份还没到货的空草稿上追加行。
|
||||
*/
|
||||
const moduleConfigLoading = ref(false)
|
||||
|
||||
/**
|
||||
* 系统语言清单(s_i18n_type):多语言面板的列定义来源。
|
||||
* 整页只加载一次(语言是系统级配置,不随模块切换变化)。
|
||||
*/
|
||||
const i18nLanguages = ref<I18nLanguage[]>([])
|
||||
let i18nLanguagesLoaded = false
|
||||
|
||||
async function loadI18nLanguages() {
|
||||
if (i18nLanguagesLoaded) return
|
||||
try {
|
||||
const rows = await loadDataApi('s_i18n_type', 'b_canuse = 1', 'b_xh ASC, b_id ASC')
|
||||
i18nLanguages.value = (rows ?? []).map((row) => ({
|
||||
code: String(row.b_id ?? ''),
|
||||
name: String(row.b_name ?? ''),
|
||||
default: Number(row.b_default) === 1,
|
||||
}))
|
||||
i18nLanguagesLoaded = true
|
||||
} catch {
|
||||
// 语言清单加载失败:面板按无列展示,不打断其它面板
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动编码的启用态:开关挂在面板头(与「同步字段」「预览查询入口」同一操作区),
|
||||
* 状态由面板回报(@update:enabled),拨动时调面板暴露的 toggleEnabled,
|
||||
@@ -399,6 +435,8 @@ watch(moduleSelectedId, (id) => {
|
||||
void loadModuleFields(id)
|
||||
void loadModuleAutoCode(id)
|
||||
void loadModuleQuery(id)
|
||||
void loadModulePowers(id)
|
||||
void loadModuleI18n(id)
|
||||
})
|
||||
|
||||
function toModuleDraft(id: string): ModuleNode | null {
|
||||
@@ -471,8 +509,96 @@ async function loadModuleQuery(id: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 卡头动作 ================= */
|
||||
/**
|
||||
* 加载本模块的权限点(s_power):b_object_type='module' + b_object_id=模块编码。
|
||||
*
|
||||
* 只加载不回写:保存编排尚未移植,权限面板的编辑都落在草稿上。
|
||||
* 排序与面板里物化的 b_xh 一致(按顺序号、编码),保证加载后看到的顺序
|
||||
* 就是保存后的顺序。
|
||||
*/
|
||||
async function loadModulePowers(id: string | null) {
|
||||
modulePowers.value = []
|
||||
if (id == null) return
|
||||
const requested = String(id)
|
||||
moduleConfigLoading.value = true
|
||||
try {
|
||||
const rows = await loadDataApi(
|
||||
's_power',
|
||||
`b_object_type = 'module' AND b_object_id = ${sqlStringLiteral(requested)}`,
|
||||
'b_xh ASC, b_id ASC',
|
||||
)
|
||||
// 选择已切走时丢弃过期响应
|
||||
if (String(moduleSelectedId.value ?? '') !== requested) return
|
||||
modulePowers.value = (rows ?? []).map((row) => ({ ...row }))
|
||||
} catch {
|
||||
// 权限点加载失败不打断页面:面板按空表展示
|
||||
} finally {
|
||||
if (String(moduleSelectedId.value ?? '') === requested) moduleConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载本模块的多语言译文(s_i18n),并透视成「一行一个资源键」的编辑形态。
|
||||
*
|
||||
* s_i18n 是长表(b_key + b_locale),而编辑时一行一个键、每个语言一列更好用,
|
||||
* 所以在这里转换一次。s_i18n **没有模块归属列**,只能按模块已知的资源键前缀
|
||||
* 反查:module.{编码}(模块名)、field.{编码}.%(字段名)、group.{编码}.%(分组标题)、
|
||||
* 以及权限点引用的 action.%(共用动词键,跨模块公用但要在本模块可见可维护)。
|
||||
*
|
||||
* 受管行(键由系统派生)标记 _managed / _autoDefault:
|
||||
* 前者不可删不可改键,后者默认语言列只读(值就是名称本身)。
|
||||
*/
|
||||
async function loadModuleI18n(id: string | null) {
|
||||
moduleI18nRows.value = []
|
||||
if (id == null) return
|
||||
const requested = String(id)
|
||||
// 前缀用 LIKE 匹配:s_i18n 无模块归属列,只能按资源键前缀反查。
|
||||
// 模块编码里的 `_` 是 LIKE 通配符,必须转义成 [_] 才不会跨模块误匹配。
|
||||
const escaped = requested.replace(/[_[\]%]/g, (char) => `[${char}]`)
|
||||
const condition = [
|
||||
`b_key = ${sqlStringLiteral(`module.${requested}`)}`,
|
||||
`b_key LIKE ${sqlStringLiteral(`field.${escaped}.%`)}`,
|
||||
`b_key LIKE ${sqlStringLiteral(`group.${escaped}.%`)}`,
|
||||
`b_key LIKE ${sqlStringLiteral('action.%')}`,
|
||||
].join(' OR ')
|
||||
try {
|
||||
const rows = await loadDataApi('s_i18n', condition, 'b_key ASC, b_locale ASC')
|
||||
// 选择已切走时丢弃过期响应
|
||||
if (String(moduleSelectedId.value ?? '') !== requested) return
|
||||
moduleI18nRows.value = pivotI18nRows(rows ?? [])
|
||||
} catch {
|
||||
// 多语言加载失败不打断页面:面板按空表展示
|
||||
}
|
||||
}
|
||||
|
||||
/** 长表 → 透视行(一行一个 b_key,每个语言一列),并标出受管行 */
|
||||
function pivotI18nRows(rows: Record<string, unknown>[]): I18nPivotRow[] {
|
||||
const moduleId = String(moduleSelectedId.value ?? '')
|
||||
const byKey = new Map<string, I18nPivotRow>()
|
||||
for (const row of rows) {
|
||||
const key = String(row.b_key ?? '')
|
||||
if (!key) continue
|
||||
let pivot = byKey.get(key)
|
||||
if (!pivot) {
|
||||
pivot = { _rowKey: createTempId(), b_key: key }
|
||||
// 受管键:模块名 / 字段名 / 分组标题由系统派生,键名不可改、行不可删
|
||||
const managed =
|
||||
key === `module.${moduleId}` ||
|
||||
key.startsWith(`field.${moduleId}.`) ||
|
||||
key.startsWith(`group.${moduleId}.`)
|
||||
if (managed) {
|
||||
pivot._managed = true
|
||||
// 模块名 / 字段名 / 分组标题的默认语言值就是名称本身,不该在这里改
|
||||
pivot._autoDefault = true
|
||||
}
|
||||
byKey.set(key, pivot)
|
||||
}
|
||||
pivot[String(row.b_locale ?? '')] = row.b_value
|
||||
}
|
||||
return [...byKey.values()]
|
||||
}
|
||||
|
||||
/* ================= 卡头动作 ================= */
|
||||
/** 卡头「更多」菜单:模块级动作(删除与左侧树右键菜单是同一入口) */
|
||||
const moreMenuItems = computed(() => [
|
||||
{ key: 'delete', label: '删除模块', icon: Trash2, danger: true },
|
||||
@@ -735,6 +861,8 @@ async function onModuleDrop({ dragKey, dropKey, dropPosition }: TreeDropInfo) {
|
||||
|
||||
onMounted(() => {
|
||||
void loadModuleTree()
|
||||
// 语言清单是多语言面板的列定义来源;整页只加载一次,与模块树互不阻塞
|
||||
void loadI18nLanguages()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -875,13 +1003,11 @@ onMounted(() => {
|
||||
v-else-if="section.key === 'fields'"
|
||||
v-model:fields="moduleFields"
|
||||
/>
|
||||
<!-- 查询设置:两屏(查询字段 / 工作队列页签),全字段勾选式,只做编辑不回写 -->
|
||||
<!-- 查询设置:工作队列页签,只做编辑不回写 -->
|
||||
<ModuleQueryPanel
|
||||
v-else-if="section.key === 'query'"
|
||||
:ref="setQueryPanel"
|
||||
v-model:query="moduleQuery"
|
||||
:module-id="String(moduleDraft?.b_id ?? '')"
|
||||
:fields="moduleFields"
|
||||
/>
|
||||
<!-- 自动编码:仅数据模块(configSections 已按 scope 过滤),单条规则 + 实时预览 -->
|
||||
<ModuleAutoCodePanel
|
||||
@@ -893,6 +1019,21 @@ onMounted(() => {
|
||||
:fields="moduleFields"
|
||||
@update:enabled="(value) => (autoCodeEnabled = value)"
|
||||
/>
|
||||
<!-- 权限:本模块权限点(s_power)+ 右侧权限库导入,只做编辑不回写 -->
|
||||
<ModulePowerPanel
|
||||
v-else-if="section.key === 'power'"
|
||||
v-model:powers="modulePowers"
|
||||
:module-id="String(moduleDraft?.b_id ?? '')"
|
||||
:module-name="String(moduleDraft?.b_name ?? '')"
|
||||
:loading="moduleConfigLoading"
|
||||
/>
|
||||
<!-- 多语言:s_i18n 透视表(一行一个资源键,每个语言一列),只做编辑不回写 -->
|
||||
<ModuleI18nPanel
|
||||
v-else-if="section.key === 'i18n'"
|
||||
v-model:rows="moduleI18nRows"
|
||||
:languages="i18nLanguages"
|
||||
:loading="moduleConfigLoading"
|
||||
/>
|
||||
<div v-else class="mm-config-placeholder">
|
||||
{{ section.label }}面板将按 fms-vue 逐步移植
|
||||
</div>
|
||||
|
||||
@@ -101,49 +101,21 @@ export interface ModuleAutoCodeRow {
|
||||
}
|
||||
|
||||
/* ================= 查询设置(s_module_schema.b_schema_type = 'query') =================
|
||||
* 查询配置在编辑器里是一份「扁平草稿」(ModuleQueryDraft,四块内容各一个数组/对象),
|
||||
* 落库形态是 query JSON(schemaVersion 3);两个方向的转换集中在这里,
|
||||
* 口径与 fms-vue 的 schemaModel.js 一致。
|
||||
* 只配一件事:工作队列页签(views)。组内单选、组间叠加,条件直接写 SQL。
|
||||
*
|
||||
* 四块内容:
|
||||
* - fields(查询字段):参与条件查询的字段;未进快捷筛选的,运行时按 b_xh 铺进「更多筛选」抽屉;
|
||||
* - views(工作队列页签):组内单选、组间叠加,条件直接写 SQL;
|
||||
* - keyword(关键词字段):只配可搜索字段,占位文案运行时按字段名生成;
|
||||
* - quickFilters(快捷筛选字段):铺在列表页查询卡上,按 order 排列。
|
||||
* 查询能力不再配置:关键词搜哪些字段、更多筛选铺哪些字段、用什么控件与算子,
|
||||
* 列表页按 s_field 就能推导(控件与算子看业务类型、关键词看文本类字段、
|
||||
* 更多筛选铺全部启用字段并按 b_xh 排序)。配一遍是重复劳动,还容易漏配 ——
|
||||
* 新模块零配置就该有查询能力。相对时间的快捷预设属于日期控件自己的能力,同样不归这里。
|
||||
*
|
||||
* 未建模属性经行上的 _node 原样带回:草稿只承载面板认识的列,
|
||||
* 控件与算子的取值口径(列表页运行时用):组件 input 输入框 / select 下拉 / date 日期 /
|
||||
* datetime 日期时间 / number 数字 / checkbox 复选框;操作符 eq 等于 / like 模糊 /
|
||||
* starts_with 开头是 / gt ge lt le 比较 / in 包含 / between 范围。
|
||||
*
|
||||
* 落库形态是 query JSON(schemaVersion 4);未建模属性经行上的 _node 原样带回,
|
||||
* JSON 里未来的扩展键不会在编辑往返中被静默丢掉。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 查询组件与操作符(queryFields[字段].component / .operator)。
|
||||
*
|
||||
* 没有界面入口——查询控件由字段的业务类型决定即可(日期字段当然是日期框、下拉字段当然是下拉),
|
||||
* 配置里已有的值由面板原样保留、不回写不认识的东西,这里只给「新参与查询的字段」兜一个初值。
|
||||
* 取值:组件 input 输入框 / select 下拉 / date 日期 / datetime 日期时间 / number 数字 / checkbox 复选框;
|
||||
* 操作符 eq 等于 / like 模糊 / starts_with 开头是 / gt ge lt le 比较 / in 包含 / between 范围。
|
||||
*/
|
||||
|
||||
/** 按字段业务类型给出查询组件 / 操作符默认值;未列出的(input / textarea)走通用 like */
|
||||
const QUERY_COMPONENT_DEFAULTS: Record<string, { component: string; operator: string }> = {
|
||||
number: { component: 'number', operator: 'eq' },
|
||||
money: { component: 'number', operator: 'eq' },
|
||||
date: { component: 'date', operator: 'ge' },
|
||||
datetime: { component: 'datetime', operator: 'ge' },
|
||||
select: { component: 'select', operator: 'eq' },
|
||||
checkbox: { component: 'checkbox', operator: 'eq' },
|
||||
}
|
||||
|
||||
/** 字段 → 查询组件 / 操作符默认值(新参与查询的字段按业务类型兜初值) */
|
||||
export function queryRowDefaults(field: ModuleFieldRow): {
|
||||
b_component: string
|
||||
b_operator: string
|
||||
} {
|
||||
const type = String(field?.b_type ?? '')
|
||||
const defaults = QUERY_COMPONENT_DEFAULTS[type] || { component: 'input', operator: 'like' }
|
||||
return { b_component: defaults.component, b_operator: defaults.operator }
|
||||
}
|
||||
|
||||
/**
|
||||
* 页签所属分组:模型上页签「一定有组」——组既是列表页上的一行页签,
|
||||
* 也是「组内单选、组间叠加」的作用域,不存在不属于任何组的页签。
|
||||
@@ -151,57 +123,6 @@ export function queryRowDefaults(field: ModuleFieldRow): {
|
||||
*/
|
||||
export const DEFAULT_VIEW_GROUP = '默认'
|
||||
|
||||
/**
|
||||
* 快捷搜索项在卡上的呈现方式:
|
||||
* control = 直接把控件摆出来(用户自己填),preset = 给一排相对时间的预设(点一下就筛)。
|
||||
*/
|
||||
export const QUERY_QUICK_MODES = [
|
||||
{ label: '直接放控件', value: 'control' },
|
||||
{ label: '给预设', value: 'preset' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 相对时间的预设。存的**是枚举不是日期**:配的时候不知道用户哪天开页面,
|
||||
* 运行时才算成具体区间(如 this_month → 本月 1 号 ~ 今天)。
|
||||
*/
|
||||
export const QUERY_DATE_PRESETS = [
|
||||
{ value: 'today', label: '今天' },
|
||||
{ value: 'yesterday', label: '昨天' },
|
||||
{ value: 'this_week', label: '本周' },
|
||||
{ value: 'last_week', label: '上周' },
|
||||
{ value: 'this_month', label: '本月' },
|
||||
{ value: 'last_month', label: '上月' },
|
||||
{ value: 'last_7_days', label: '最近7天' },
|
||||
{ value: 'last_30_days', label: '最近30天' },
|
||||
]
|
||||
|
||||
/** 查询字段行(草稿):身份是 _rowKey,落库后按 b_field 收进 queryFields */
|
||||
export interface ModuleQueryFieldRow {
|
||||
_rowKey: string
|
||||
/** 未建模属性(原 queryFields[字段] 的其余键),回写时原样带回 */
|
||||
_node: Record<string, unknown>
|
||||
b_module_id: string
|
||||
b_field: string | null
|
||||
b_component: string
|
||||
b_operator: string
|
||||
b_default_value: string | null
|
||||
/** 卡上的呈现方式:control 直接放控件 / preset 给一排预设(不写按 control) */
|
||||
b_mode: string
|
||||
/** preset 模式下要露出的预设(相对时间枚举) */
|
||||
b_presets: string[]
|
||||
/** preset 模式下默认选中哪个预设(空 = 打开页面不带条件) */
|
||||
b_default_preset: string | null
|
||||
b_canuse: number
|
||||
b_xh: number
|
||||
/**
|
||||
* 由「列表」勾选自动补出的行(fms 语义):面板里改过组件 / 算子 / 默认值后摘掉标记,
|
||||
* 转为自定义行,取消列表勾选时不再被自动回收。列表面板尚未移植,
|
||||
* 保留该列只为与落库结构一致。
|
||||
*/
|
||||
fromList: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 工作队列页签行(草稿):code 为空的行不落库(页签没有编码就无法被运行时选中) */
|
||||
export interface ModuleQueryViewRow {
|
||||
_rowKey: string
|
||||
@@ -216,52 +137,17 @@ export interface ModuleQueryViewRow {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 快捷筛选行(草稿) */
|
||||
export interface ModuleQueryQuickRow {
|
||||
_rowKey: string
|
||||
field: string
|
||||
order: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 查询设置草稿:四个分节的编辑态,与落库 JSON 经下面两个函数双向适配 */
|
||||
/** 查询设置草稿:与落库 JSON 经下面两个函数双向适配 */
|
||||
export interface ModuleQueryDraft {
|
||||
fields: ModuleQueryFieldRow[]
|
||||
views: ModuleQueryViewRow[]
|
||||
keyword: { fields: string[] }
|
||||
quickFilters: ModuleQueryQuickRow[]
|
||||
}
|
||||
|
||||
/** query JSON → 编辑器草稿 */
|
||||
export function querySchemaToDraft(schema: SchemaJson | null, moduleId: string): ModuleQueryDraft {
|
||||
/** query JSON → 编辑器草稿(只取页签;其余键不再建模,由回写统一清理) */
|
||||
export function querySchemaToDraft(schema: SchemaJson | null): ModuleQueryDraft {
|
||||
const source = (schema && typeof schema === 'object' ? schema : {}) as SchemaJson & {
|
||||
queryFields?: Record<string, unknown>
|
||||
views?: Array<Record<string, unknown>>
|
||||
keywordSearch?: { fields?: unknown[] }
|
||||
quickFilters?: Array<Record<string, unknown>>
|
||||
defaultViewCode?: string
|
||||
}
|
||||
const fieldRows = Object.entries(source.queryFields || {}).map(([field, config], index) => {
|
||||
const node =
|
||||
config && typeof config === 'object' ? { ...(config as Record<string, unknown>) } : {}
|
||||
const fromList = node.fromList === true
|
||||
delete node.fromList
|
||||
return {
|
||||
_rowKey: createTempId(),
|
||||
_node: node,
|
||||
b_module_id: String(moduleId),
|
||||
b_field: field,
|
||||
b_component: String(node.component ?? 'input'),
|
||||
b_operator: String(node.operator ?? 'like'),
|
||||
b_default_value: node.defaultValue == null ? null : String(node.defaultValue),
|
||||
b_mode: String(node.mode ?? 'control'),
|
||||
b_presets: Array.isArray(node.presets) ? node.presets.map((item) => String(item)) : [],
|
||||
b_default_preset: node.defaultPreset == null ? null : String(node.defaultPreset),
|
||||
b_canuse: 1,
|
||||
b_xh: (index + 1) * 10,
|
||||
fromList,
|
||||
} as ModuleQueryFieldRow
|
||||
})
|
||||
// 旧结构的 defaultViewCode 等价于「该页签 isDefault」,加载时折进来
|
||||
const legacyDefaultCode = String(source.defaultViewCode ?? '')
|
||||
const views = (source.views || []).map((view, index) => {
|
||||
@@ -278,18 +164,7 @@ export function querySchemaToDraft(schema: SchemaJson | null, moduleId: string):
|
||||
conditionSql: String(view?.conditionSql ?? '').trim(),
|
||||
} as ModuleQueryViewRow
|
||||
})
|
||||
return {
|
||||
fields: fieldRows,
|
||||
views,
|
||||
keyword: {
|
||||
fields: (source.keywordSearch?.fields || []).map((field) => String(field)),
|
||||
},
|
||||
quickFilters: (source.quickFilters || []).map((entry, index) => ({
|
||||
_rowKey: createTempId(),
|
||||
field: String(entry?.field ?? ''),
|
||||
order: Number(entry?.order) || (index + 1) * 10,
|
||||
})),
|
||||
}
|
||||
return { views }
|
||||
}
|
||||
|
||||
/** 编辑器草稿 → query JSON(未建模属性经 _node / base 保留,建模属性以草稿为准) */
|
||||
@@ -298,49 +173,22 @@ export function queryDraftToSchema(
|
||||
baseSchema?: SchemaJson | null,
|
||||
): SchemaJson {
|
||||
const base = { ...(baseSchema || createDefaultSchema('query')) }
|
||||
// 旧结构的这几个键一律不再使用,改写时一并清掉
|
||||
// 查询能力由列表页按 s_field 推导:这几个配置键不再使用,改写时一并清掉
|
||||
// (queryFields 里还挂着历史遗留的 mode / presets / defaultPreset,随整个键一起清)
|
||||
delete base.queryFields
|
||||
delete base.keywordSearch
|
||||
delete base.quickFilters
|
||||
// 更早版本的遗留键
|
||||
delete base.groups
|
||||
delete base.quick
|
||||
delete base.filterGroups
|
||||
delete base.defaultViewCode
|
||||
|
||||
const queryFields: Record<string, unknown> = {}
|
||||
for (const row of draft?.fields || []) {
|
||||
const field = String(row.b_field ?? '').trim()
|
||||
if (!field) continue
|
||||
const config: Record<string, unknown> = { ...(row._node || {}) }
|
||||
if (row.b_component) config.component = row.b_component
|
||||
else delete config.component
|
||||
if (row.b_operator) config.operator = row.b_operator
|
||||
else delete config.operator
|
||||
if (row.b_default_value != null && row.b_default_value !== '') {
|
||||
config.defaultValue = row.b_default_value
|
||||
} else {
|
||||
delete config.defaultValue
|
||||
}
|
||||
// 呈现方式与预设:control 是默认形态,不写进 JSON(省得每条都挂一堆默认值)
|
||||
if (row.b_mode === 'preset') {
|
||||
config.mode = 'preset'
|
||||
const presets = (row.b_presets || []).map((item) => String(item)).filter(Boolean)
|
||||
if (presets.length) config.presets = presets
|
||||
else delete config.presets
|
||||
if (row.b_default_preset) config.defaultPreset = row.b_default_preset
|
||||
else delete config.defaultPreset
|
||||
} else {
|
||||
delete config.mode
|
||||
delete config.presets
|
||||
delete config.defaultPreset
|
||||
}
|
||||
if (row.fromList) config.fromList = true
|
||||
else delete config.fromList
|
||||
queryFields[field] = config
|
||||
}
|
||||
|
||||
const views = (draft?.views || [])
|
||||
.map((row) => {
|
||||
const code = String(row.code ?? '').trim()
|
||||
if (!code) return null
|
||||
const view: Record<string, unknown> = { ...(row._node || {}), code }
|
||||
const view: Record<string, unknown> = { ...row._node, code }
|
||||
if (String(row.label ?? '').trim()) view.label = String(row.label).trim()
|
||||
else delete view.label
|
||||
// 页签一定有分组:空的一律落到默认组(历史配置在此被补全)
|
||||
@@ -357,22 +205,10 @@ export function queryDraftToSchema(
|
||||
})
|
||||
.filter((view): view is Record<string, unknown> => view != null)
|
||||
|
||||
const keywordFields = [
|
||||
...new Set((draft?.keyword?.fields || []).map((field) => String(field)).filter(Boolean)),
|
||||
]
|
||||
|
||||
return {
|
||||
...base,
|
||||
schemaVersion: 3,
|
||||
schemaVersion: 4,
|
||||
views,
|
||||
keywordSearch: { fields: keywordFields },
|
||||
quickFilters: (draft?.quickFilters || [])
|
||||
.map((row, index) => ({
|
||||
field: String(row.field ?? '').trim(),
|
||||
order: Number(row.order) || (index + 1) * 10,
|
||||
}))
|
||||
.filter((entry) => entry.field),
|
||||
queryFields,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { RotateCw, Save, Table, TriangleAlert } from '@lucide/vue'
|
||||
import { G3Message, G3Table } from '@g3soft/ui'
|
||||
import { checkPrecision, G3Message, G3Table } from '@g3soft/ui'
|
||||
import type { G3TableColumn, TableExpose, TableRow } from '@g3soft/ui'
|
||||
|
||||
/**
|
||||
@@ -14,30 +14,38 @@ import type { G3TableColumn, TableExpose, TableRow } from '@g3soft/ui'
|
||||
* 换成通用列反而会丢掉「贴近真实业务密度」这一点。
|
||||
*
|
||||
* 覆盖的能力(按「配置一处、演示一处」组织,方便照抄):
|
||||
* 1. 编辑器类型:text / number / select / checkbox
|
||||
* 1. 编辑器类型:text / number / date / select / checkbox
|
||||
* `date` 点格即弹日历、也支持手输,值始终是字符串(见「开航日 / 截单日」两列)
|
||||
* 2. 校验态:error(红底 + ! 角标)与 warning(琥珀底 + ! 角标)
|
||||
* 3. 禁用态:editor.disabled(整列禁用 / 按行条件禁用)
|
||||
* 4. 只读列:没配 editor 的列在编辑期自动灰底
|
||||
* 5. 当前行高亮:受控 activeRowKey + @row-click(与 fms-vue 的 FmsTable 同款写法)
|
||||
* 6. 提交流程:保存时整表校验,未通过则定位到首个错误格
|
||||
* 7. **左右冻结列**:左冻结「序号 / 订舱号 / 客户」,右冻结「备注」,横向滚动时两侧不动
|
||||
* 6. 提交流程:保存时整表校验(含组件导出的 `checkPrecision`),未通过则定位到首个错误格
|
||||
* 7. **金额精度**:「海运费」列用 `editor.precision` 声明两位小数 —— 超出即标红、
|
||||
* 悬停看原因,且**不改数据**;展示细节(千分位 / ¥ 前缀)由列上的 `format` 管
|
||||
* 8. **左右冻结列**:左冻结「序号 / 订舱号 / 客户」,右冻结「备注」,横向滚动时两侧不动
|
||||
*
|
||||
* 关于冻结列(第 7 条),配置只有列上的一个 `fixed`,但有三条硬约束:
|
||||
* 关于冻结列(第 8 条),配置只有列上的一个 `fixed`,但有三条硬约束:
|
||||
* · `fixed: 'left'` 的列要排在 columns **最前面**、`'right'` 的排在**最后面**;
|
||||
* · 固定列**必须写 `width`**。左侧固定列的吸附位置由「它左边那些 left 固定列的
|
||||
* 宽度累加」算出来(底座 `useGetFixedColPosition`),没写宽度就没有吸附位移;
|
||||
* · 想看到效果,**总列宽必须超过容器宽**(否则整表都看得见,固定与不固定一个样)——
|
||||
* 本页刻意加到 21 列业务列来制造横向溢出。
|
||||
* 本页刻意加到 22 列业务列来制造横向溢出。
|
||||
* 序号列(`seq`)由组件内部注入、业务侧不用配,它**本身也是左固定的**
|
||||
* (G3Table 注入时写了 `fixed: 'left'`,与选择列一致),所以左冻结区依次是
|
||||
* 「序号 → 订舱号 → 客户」,横向滚动时行号不会跟着丢。
|
||||
* 阴影开关 `fixed-col-shadow` 建议打开:冻结列与滚过去的列同为白底,没有这层
|
||||
* 渐变就看不出边界在哪。
|
||||
*
|
||||
* **本页始终处于可编辑状态**(没有「编辑明细」开关按钮):
|
||||
* 进去就能直接点任意单元格改,`editable` 用组件默认值 true。
|
||||
* 本页**始终具备编辑能力**(没有「编辑明细」开关按钮),`editable` 用组件默认值 true。
|
||||
* 没有任何行被默认激活 —— 编辑目标是用户点出来的。
|
||||
*
|
||||
* ⚠️ 键盘分工(详见 docs/content/ui/data/table.md 的「键盘:单击即编辑,方向键分工」):
|
||||
* · **单击格即进入编辑**,可以直接打字(同行换列时重新挂载并全选,输入即替换);
|
||||
* · 编辑态里**左右**在输入框内移动光标(`Shift+左右` 选文本)、**上下**切到上 / 下一行的同一列;
|
||||
* · `Enter` 确认并下移到下一行同列;备注这类多行格里的 `Enter` 是换行;
|
||||
* · `Esc` 放弃本次编辑(回滚到进入前的值);`Tab` / `Shift+Tab` 跳过只读列跳下一个可编辑格。
|
||||
*
|
||||
* 数据为本地假数据,不请求接口。
|
||||
*/
|
||||
|
||||
@@ -69,6 +77,13 @@ interface BookingRow extends TableRow {
|
||||
grossWeight: number
|
||||
/** 体积,单位 CBM */
|
||||
volume: number
|
||||
/**
|
||||
* 海运费(金额)。
|
||||
*
|
||||
* 用来演示「数值精度」这条链路:`editor.precision` 声明两位小数,
|
||||
* 超出即标红(且**不改数据**);展示细节(千分位 / ¥ 前缀)走列上的 `format`。
|
||||
*/
|
||||
freight: number
|
||||
/** 业务员(对应 SALESMAN_OPTIONS 的 value) */
|
||||
salesman: string
|
||||
etd: string
|
||||
@@ -192,6 +207,15 @@ function makeRow(index: number): BookingRow {
|
||||
// 毛重按箱量估:20 尺约 12~19 吨,40 尺按 2 倍箱量已经在 containerCount 里计入
|
||||
grossWeight: containerCount * (12 + Math.floor(seeded(index * 11.3) * 8)),
|
||||
volume: containerCount * (18 + Math.floor(seeded(index * 13.7) * 12)),
|
||||
/**
|
||||
* 海运费:常规行给**两位以内**的金额(展示千分位与两位小数的格式化效果),
|
||||
* 每第 5 行刻意给**三位小数** —— 这样一进页面就能看到「精度超了」标红的样子,
|
||||
* 不需要自己动手输。
|
||||
*/
|
||||
freight:
|
||||
index % 5 === 3
|
||||
? Math.round(seeded(index * 17.3) * 9000000) / 1000
|
||||
: Math.round(seeded(index * 17.3) * 4000000) / 100,
|
||||
salesman: pick(SALESMAN_OPTIONS, index * 12.9).value,
|
||||
etd: `2026-${m}-${d}`,
|
||||
docClose: `2026-${String((index % 12) + 1).padStart(2, '0')}-${String(Math.max(1, Number(d) - 3)).padStart(2, '0')}`,
|
||||
@@ -394,17 +418,54 @@ const columns = ref<G3TableColumn<TableRow>[]>([
|
||||
align: 'right',
|
||||
editor: { type: 'number' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'freight',
|
||||
title: '海运费',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
/**
|
||||
* 展示:千分位 + `¥` 前缀。
|
||||
*
|
||||
* ⚠️ **小数位不写在这里** —— 它归 `editor.precision`(一处真源),
|
||||
* `format` 只管「怎么显示」。两者都要写时以 `format.decimals` 为准,
|
||||
* 但那是留给「显示位数 ≠ 数据精度」的例外,常规场景别用。
|
||||
*/
|
||||
format: { thousands: true, prefix: '¥' },
|
||||
editor: {
|
||||
type: 'number',
|
||||
/**
|
||||
* 精度:两位小数。
|
||||
*
|
||||
* 超出即 error —— 红底 + 左侧竖条,悬停浮层显示「最多 2 位小数」,
|
||||
* 而这条规则由**组件内置**,不需要在这里写任何与小数位有关的 `validate`。
|
||||
* 且组件**不改数据**:用户输的 `123.456` 原样留在行里,由用户自己改。
|
||||
*/
|
||||
precision: 2,
|
||||
validate: (v: unknown) => Number(v) >= 0 || '海运费不能为负数',
|
||||
},
|
||||
},
|
||||
{
|
||||
dataIndex: 'etd',
|
||||
title: '开航日',
|
||||
width: 110,
|
||||
editor: { type: 'text' },
|
||||
// 比纯文本列宽一些:日期框里还要放清除按钮,110px 会把日期挤到省略号
|
||||
width: 140,
|
||||
/**
|
||||
* 日期编辑器。点格即弹日历(不必先点输入框),也能直接手输 `2026-03-15`。
|
||||
*
|
||||
* 值始终是**字符串**、不做 Date ↔ string 转换:面板精度与绑定值格式都由
|
||||
* `format` 决定,默认 `YYYY-MM-DD`,正好是这一列数据的存量格式(见 makeRow)。
|
||||
*/
|
||||
editor: { type: 'date' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'docClose',
|
||||
title: '截单日',
|
||||
width: 110,
|
||||
editor: { type: 'text' },
|
||||
width: 140,
|
||||
/**
|
||||
* 同样是日期列,但**带时间精度**:`format` 写全到分钟,面板会多出时间列,
|
||||
* 而且选完要点「确定」才提交(只有日精度时点一下日期就完成)。
|
||||
*/
|
||||
editor: { type: 'date', format: 'YYYY-MM-DD HH:mm' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'salesman',
|
||||
@@ -474,17 +535,37 @@ function findFirstError(): { rowIndex: number; colIndex: number; message: string
|
||||
for (let c = 0; c < columns.value.length; c += 1) {
|
||||
const col = columns.value[c]
|
||||
const editor = col.editor
|
||||
if (!editor?.validate) continue
|
||||
// 注意判据是 `!editor` 而不是 `!editor?.validate`:配了 precision 的列
|
||||
// 可能没有自己的 validate,但它的精度规则同样要参与提交把关。
|
||||
if (!editor) continue
|
||||
if (typeof editor.disabled === 'function' ? editor.disabled(row) : editor.disabled) continue
|
||||
const result = editor.validate(row[col.dataIndex], row, col)
|
||||
if (typeof result === 'string' && result.length > 0) {
|
||||
return { rowIndex: r, colIndex: c, message: result }
|
||||
}
|
||||
|
||||
const message = validateCell(row, col)
|
||||
if (message) return { rowIndex: r, colIndex: c, message }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 跑一格的**全部**校验规则,返回失败原因(`''` = 通过)。
|
||||
*
|
||||
* 两条规则缺一不可:
|
||||
* 1. `editor.validate` —— 业务规则;
|
||||
* 2. `checkPrecision` —— 组件内置的**精度**规则,而导出的就是表格里用的那个函数。
|
||||
* 这里若只跑第 1 条,就会出现「格子红了、但保存能过」——
|
||||
* 校验状态与提交把关不一致是最难查的那类问题,所以宁可多写这两行。
|
||||
*/
|
||||
function validateCell(row: TableRow, col: G3TableColumn<TableRow>): string {
|
||||
const editor = col.editor
|
||||
if (!editor) return ''
|
||||
const precision =
|
||||
typeof editor.precision === 'function' ? editor.precision(row) : editor.precision
|
||||
const result =
|
||||
editor.validate?.(row[col.dataIndex], row, col) || checkPrecision(row[col.dataIndex], precision)
|
||||
return typeof result === 'string' ? result : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 表格实例引用,用于「保存失败时定位到首个错误格」。
|
||||
*
|
||||
@@ -532,10 +613,9 @@ const errorCount = computed(() => {
|
||||
let n = 0
|
||||
for (const row of rows.value) {
|
||||
for (const col of columns.value) {
|
||||
const editor = col.editor
|
||||
if (!editor?.validate) continue
|
||||
const result = editor.validate(row[col.dataIndex], row, col)
|
||||
if (typeof result === 'string' && result.length > 0) n += 1
|
||||
// 与 findFirstError 用**同一个** validateCell:两处若各写一套,
|
||||
// 汇总数与「保存时定位到的那个错」迟早对不上。
|
||||
if (validateCell(row, col)) n += 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 查询配置面板(query JSON,schemaVersion 4):只配工作队列页签。
|
||||
*
|
||||
* 这里测的是**结构与数据往返**:分组渲染、建组即建页签、编码自动派生、
|
||||
* 组名就地改要改整组、旧的查询字段配置在回写时被清掉。
|
||||
* G3Table 在 happy-dom 里没有真实尺寸,行内编辑与行拖拽是组件库自己的活,
|
||||
* 用桩替换掉(本面板只负责把列定义与数据源接上去)。
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ModuleQueryPanel from '@/views/module/module-management/ModuleQueryPanel.vue'
|
||||
import type { SchemaJson } from '@/utils/schemaJson'
|
||||
|
||||
function schema(views: Array<Record<string, unknown>> = []): SchemaJson {
|
||||
return { schemaVersion: 4, views }
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟父级 v-model:query:emit 回来要显式写回 prop,
|
||||
* 否则复现不了「草稿回写 → 父级再传下来」这一轮往返。
|
||||
*/
|
||||
function mountPanel(views: Array<Record<string, unknown>> = []) {
|
||||
const wrapper = mount(ModuleQueryPanel, {
|
||||
props: {
|
||||
query: schema(views),
|
||||
'onUpdate:query': (value: SchemaJson) => wrapper.setProps({ query: value }),
|
||||
},
|
||||
global: { stubs: { G3Table: true } },
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/** 当前草稿里的页签行(面板对外就是这份 query JSON) */
|
||||
function draftViews(wrapper: ReturnType<typeof mountPanel>): Array<Record<string, unknown>> {
|
||||
return (wrapper.props('query') as SchemaJson).views as Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
function groupNames(wrapper: ReturnType<typeof mountPanel>): string[] {
|
||||
return wrapper.findAll('.mm-groups__name').map((el) => (el.element as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
/** 改组名:业务上走 change(失焦 / 回车提交),这里照同一条路触发 */
|
||||
async function renameFirstGroup(wrapper: ReturnType<typeof mountPanel>, name: string) {
|
||||
const input = wrapper.find('.mm-groups__name')
|
||||
await input.setValue(name)
|
||||
await input.trigger('change')
|
||||
await wrapper.vm.$nextTick()
|
||||
}
|
||||
|
||||
describe('查询配置面板(工作队列页签)', () => {
|
||||
it('按 group 归组:分组条上每组一个 chip,顺序跟着组内最小 order 走', () => {
|
||||
const wrapper = mountPanel([
|
||||
{ code: 'sail', label: '已开航', group: '状态', order: 30 },
|
||||
{ code: 'all', label: '全部', group: '状态', order: 10 },
|
||||
{ code: 'month', label: '本月', group: '时间', order: 20 },
|
||||
])
|
||||
|
||||
expect(groupNames(wrapper)).toEqual(['状态', '时间'])
|
||||
})
|
||||
|
||||
it('历史配置没写 group 的页签落到「默认」组(组从行反推,不存在无组的页签)', () => {
|
||||
const wrapper = mountPanel([{ code: 'all', label: '全部', order: 10 }])
|
||||
|
||||
expect(groupNames(wrapper)).toEqual(['默认'])
|
||||
})
|
||||
|
||||
it('新增页签组:建组的同时建出组内第一个页签,编码自动派生(空编码落库会被丢掉)', async () => {
|
||||
const wrapper = mountPanel()
|
||||
|
||||
await wrapper.find('.mm-query__bar button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(groupNames(wrapper)).toEqual(['分组 1'])
|
||||
const views = draftViews(wrapper)
|
||||
expect(views).toHaveLength(1)
|
||||
expect(views[0].group).toBe('分组 1')
|
||||
expect(views[0].code).toBe('view_1')
|
||||
// 组里第一个页签自动成为组内默认:一个组总要有一个默认选中的
|
||||
expect(views[0].isDefault).toBe(true)
|
||||
})
|
||||
|
||||
it('再加一组时编码继续派生,不会与已有页签撞号', async () => {
|
||||
const wrapper = mountPanel([{ code: 'view_1', label: '全部', group: '状态', order: 10 }])
|
||||
|
||||
await wrapper.find('.mm-query__bar button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
expect(draftViews(wrapper)[1].code).toBe('view_2')
|
||||
})
|
||||
|
||||
it('组名就地改:改的是整组(组是共享实体,不能只改一行)', async () => {
|
||||
const wrapper = mountPanel([
|
||||
{ code: 'all', label: '全部', group: '状态', order: 10 },
|
||||
{ code: 'sail', label: '已开航', group: '状态', order: 20 },
|
||||
])
|
||||
|
||||
await renameFirstGroup(wrapper, '运行状态')
|
||||
|
||||
expect(groupNames(wrapper)).toEqual(['运行状态'])
|
||||
expect(draftViews(wrapper).every((row) => row.group === '运行状态')).toBe(true)
|
||||
})
|
||||
|
||||
it('组名改成已存在的组名时拒绝(否则两个组会被无声合并)', async () => {
|
||||
const wrapper = mountPanel([
|
||||
{ code: 'a', label: '全部', group: '状态', order: 10 },
|
||||
{ code: 'b', label: '本月', group: '时间', order: 20 },
|
||||
])
|
||||
|
||||
await renameFirstGroup(wrapper, '时间')
|
||||
|
||||
expect(groupNames(wrapper)).toEqual(['状态', '时间'])
|
||||
})
|
||||
|
||||
it('回写时把不再使用的 queryFields / keywordSearch / quickFilters 一并清掉', async () => {
|
||||
const wrapper = mountPanel()
|
||||
// 载入一份带旧配置的 schema:适配层只认 views,其余键由回写统一清理
|
||||
await wrapper.setProps({
|
||||
query: {
|
||||
schemaVersion: 3,
|
||||
views: [{ code: 'all', label: '全部', order: 10 }],
|
||||
keywordSearch: { fields: ['b_name'] },
|
||||
quickFilters: [{ field: 'b_name', order: 10 }],
|
||||
queryFields: { b_name: { component: 'input', operator: 'like' } },
|
||||
},
|
||||
})
|
||||
// 触发一次回写(新增一组)
|
||||
await wrapper.find('.mm-query__bar button').trigger('click')
|
||||
await wrapper.vm.$nextTick()
|
||||
|
||||
const next = wrapper.props('query') as SchemaJson
|
||||
expect(next.queryFields).toBeUndefined()
|
||||
expect(next.keywordSearch).toBeUndefined()
|
||||
expect(next.quickFilters).toBeUndefined()
|
||||
expect(next.schemaVersion).toBe(4)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user