This commit is contained in:
oneao committed 2026-10-15 22:47:42 +08:00
1 parent bf9dca19de
commit e7c84a82a2
31 files changed
+4063 -2362

No files matched your search

@@ -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;
+127
View File
@@ -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
+3 -7
View File
@@ -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)
})
})