20260914223013
This commit is contained in:
1 parent
549c8bd2eb
commit
63514476d7
60 files changed
+6478
-2004
No files matched your search
@@ -0,0 +1,22 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 联系人详情 —— 新增与编辑都在本页完成。
|
||||
*
|
||||
* 路由契约:`/base/contact/detail`
|
||||
* - 不带参数(`route.query.id` 为空)表示新建;
|
||||
* - `?id=xxx` 表示编辑 b_contact.b_id = xxx;
|
||||
* - 顶部工具栏的保存 / 重置 / 删除与表单均尚未实现。
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contact-detail-page" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contact-detail-page {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,124 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 联系人(b_contact)。
|
||||
* 页面结构、权限与编辑入口尚未实现,先占位以便路由与目录结构就位。
|
||||
* 联系人(b_contact)—— 元数据驱动的业务列表页。
|
||||
*
|
||||
* 与 fms-vue 内既有页面(base/othercompany)保持一致:
|
||||
* - 列表 / 查询 / 分页 / 栏位设置:FmsModuleListPage 按 data-code=`v_b_contact` 自渲染;
|
||||
* - 新增与编辑都在详情页(base-contact-detail)完成,本页只做跳转,不做行内编辑;
|
||||
* - 页面只保留业务规则:权限点驱动按钮显隐、行删除、批量删除。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message, Modal } from '@/components/ui'
|
||||
import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
|
||||
const MODULE_CODE = 'v_b_contact'
|
||||
|
||||
const permissionStore = usePermissionStore()
|
||||
const canCreate = computed(() => permissionStore.canPower(MODULE_CODE, 'create'))
|
||||
const canDelete = computed(() => permissionStore.canPower(MODULE_CODE, 'delete'))
|
||||
|
||||
const router = useRouter()
|
||||
const listRef = ref(null)
|
||||
const selectedRowKeys = ref([])
|
||||
/** 单行删除进行中:阻止行操作按钮重复触发 */
|
||||
const rowSaving = ref(false)
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
}))
|
||||
|
||||
/** 新增:不带参数跳详情页;编辑同页完成,用 ?id=xxx 传主键 */
|
||||
function onCreate() {
|
||||
router.push({ name: 'base-contact-detail' })
|
||||
}
|
||||
|
||||
/* ================= 行操作(删除) ================= */
|
||||
// 删除走 FmsTable 内置的 confirm 状态化弹窗(见 createCellRenderer.js),
|
||||
// 与 i18n-type 等页面一致;不再手工 Modal.confirm,减少一层控制流。
|
||||
const rowActions = computed(() => {
|
||||
const actions = []
|
||||
if (canDelete.value) {
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
type: 'delete',
|
||||
label: '删除',
|
||||
confirm: { title: '删除联系人', content: '确定删除该联系人吗?此操作不可恢复。' },
|
||||
disabled: () => rowSaving.value,
|
||||
onClick: (row) => removeRow(row),
|
||||
})
|
||||
}
|
||||
return actions
|
||||
})
|
||||
|
||||
async function removeRow(row) {
|
||||
if (rowSaving.value) return
|
||||
rowSaving.value = true
|
||||
try {
|
||||
// 走列表组件的删除通道:主键 / 保存表由 FmsModuleListPage 内部解析
|
||||
await listRef.value?.deleteSelected([row.b_id])
|
||||
selectedRowKeys.value = selectedRowKeys.value.filter((key) => key !== row.b_id)
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
} finally {
|
||||
rowSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 批量删除 ================= */
|
||||
function confirmBatchDelete() {
|
||||
const keys = [...selectedRowKeys.value]
|
||||
if (!keys.length) return
|
||||
Modal.confirm({
|
||||
title: `确定删除选中的 ${keys.length} 条联系人?`,
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await listRef.value?.deleteSelected(keys)
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
throw error // 保持确认框打开,便于重试
|
||||
}
|
||||
Message.success('删除成功')
|
||||
selectedRowKeys.value = []
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contact-page" />
|
||||
<div class="contact-page">
|
||||
<FmsModuleListPage
|
||||
ref="listRef"
|
||||
:data-code="MODULE_CODE"
|
||||
:row-selection="rowSelection"
|
||||
:row-actions="rowActions"
|
||||
:inline-action-limit="2"
|
||||
>
|
||||
<template #actions="{ loading }">
|
||||
<Button v-if="canCreate" type="primary" :disabled="loading || rowSaving" @click="onCreate">
|
||||
<Plus :size="14" />
|
||||
新增
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDelete"
|
||||
type="danger"
|
||||
:disabled="loading || rowSaving || !selectedRowKeys.length"
|
||||
@click="confirmBatchDelete"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</Button>
|
||||
</template>
|
||||
</FmsModuleListPage>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 新增 / 修改分类弹窗(b_othercompany_bmfl)。
|
||||
*
|
||||
* 数据契约:本组件只持有一份打开时快照的本地草稿;确定时把校验通过的
|
||||
* { b_id, b_name, b_xh } 交给父级落库,父级再重载分类树。
|
||||
* 上级分类由父级传入 parentId 决定(新增时点 + / 右键的落点),弹窗内只读展示。
|
||||
* 分类编码是主键(创建后不可修改),因此修改态下禁用,并做全局唯一性校验。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Button, Form, FormItem, Input, Message, Modal } from '@/components/ui'
|
||||
import { nextSiblingXh } from './utils'
|
||||
|
||||
const props = defineProps({
|
||||
/** 受控打开状态 */
|
||||
open: { type: Boolean, default: false },
|
||||
/** 修改时的分类行;新增时为 null */
|
||||
editing: { type: Object, default: null },
|
||||
/** 新增分类的上级编码;null 表示顶级 */
|
||||
parentId: { type: [String, Number], default: null },
|
||||
/** 全量分类行(编码全局查重 + 上级名称展示) */
|
||||
categories: { type: Array, default: () => [] },
|
||||
/** 保存中:禁用表单与按钮 */
|
||||
saving: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'submit'])
|
||||
|
||||
const isEdit = computed(() => !!props.editing)
|
||||
|
||||
/** 本地草稿:打开时快照,确定时提交 */
|
||||
const draft = ref({ b_id: '', b_name: '', b_xh: 10 })
|
||||
|
||||
/** 上级分类名称(顶级显示「全部分类」) */
|
||||
const parentName = computed(() => {
|
||||
if (props.parentId == null || props.parentId === '') return '全部分类'
|
||||
const parent = props.categories.find((row) => String(row.b_id) === String(props.parentId))
|
||||
return parent?.b_name || String(props.parentId)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
if (props.editing) {
|
||||
draft.value = {
|
||||
b_id: props.editing.b_id ?? '',
|
||||
b_name: props.editing.b_name ?? '',
|
||||
b_xh: Number(props.editing.b_xh) || 0,
|
||||
}
|
||||
return
|
||||
}
|
||||
draft.value = {
|
||||
b_id: '',
|
||||
b_name: '',
|
||||
b_xh: nextSiblingXh(props.categories, props.parentId),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function setField(field, value) {
|
||||
draft.value = { ...draft.value, [field]: value }
|
||||
}
|
||||
|
||||
const formRef = ref(null)
|
||||
|
||||
const formRules = computed(() => ({
|
||||
b_id: [
|
||||
{ required: true, message: '请填写分类编码' },
|
||||
{ pattern: /^[A-Za-z0-9_-]+$/, message: '分类编码只能包含字母、数字、下划线或短横线' },
|
||||
{ max: 150, message: '分类编码长度不能超过 150' },
|
||||
{
|
||||
// b_id 是主键,重复即入库失败;这里提前拦下并给出明确提示(修改自身时排除)
|
||||
validator: (value) => {
|
||||
const code = String(value ?? '').trim()
|
||||
if (!code) return true
|
||||
const selfId = String(props.editing?.b_id ?? '')
|
||||
const duplicated = props.categories.some(
|
||||
(row) => String(row.b_id) === code && String(row.b_id) !== selfId,
|
||||
)
|
||||
return duplicated ? '分类编码已存在' : true
|
||||
},
|
||||
},
|
||||
],
|
||||
b_name: [
|
||||
{ required: true, message: '请填写分类名称' },
|
||||
{ max: 200, message: '分类名称长度不能超过 200' },
|
||||
],
|
||||
}))
|
||||
|
||||
async function onOk() {
|
||||
const errors = (await formRef.value?.validateAll()) ?? []
|
||||
if (errors.length) {
|
||||
Message.warning('请修正表单中标红的错误项')
|
||||
return
|
||||
}
|
||||
emit('submit', {
|
||||
b_id: String(draft.value.b_id ?? '').trim(),
|
||||
b_name: String(draft.value.b_name ?? '').trim(),
|
||||
b_xh: Number(draft.value.b_xh) || 0,
|
||||
})
|
||||
}
|
||||
|
||||
function onCancel() {
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
:title="isEdit ? '修改分类' : '新增分类'"
|
||||
:width="520"
|
||||
:mask-closable="false"
|
||||
:confirm-loading="saving"
|
||||
@cancel="onCancel"
|
||||
>
|
||||
<Form ref="formRef" :model="draft" :rules="formRules" layout="vertical" :gap="16">
|
||||
<FormItem label="上级分类" name="b_parent_id">
|
||||
<Input :model-value="parentName" disabled />
|
||||
</FormItem>
|
||||
<FormItem label="分类编码" name="b_id" required>
|
||||
<Input
|
||||
:model-value="draft.b_id"
|
||||
:disabled="isEdit"
|
||||
placeholder="主键即编码,保存后不可修改"
|
||||
@update:model-value="(v) => setField('b_id', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="分类名称" name="b_name" required>
|
||||
<Input :model-value="draft.b_name" @update:model-value="(v) => setField('b_name', v)" />
|
||||
</FormItem>
|
||||
<FormItem label="排序号" name="b_xh">
|
||||
<Input
|
||||
:model-value="draft.b_xh"
|
||||
type="number"
|
||||
@update:model-value="(v) => setField('b_xh', Number(v) || 0)"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<template #footer>
|
||||
<Button type="outline" :disabled="saving" @click="onCancel">取消</Button>
|
||||
<Button type="primary" :loading="saving" @click="onOk">确定</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -1,247 +0,0 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 往来单位分类树面板(othercompany 左侧)。
|
||||
*
|
||||
* 职责:搜索框、树展示,以及展开 / 选中 / 右键菜单 / 拖拽的 UI 交互。
|
||||
* 数据契约:树数据、选中项、加载态由父级唯一持有,本组件只读并通过事件上报意图:
|
||||
* select / add / edit / delete / refresh / drop。
|
||||
* 树的加载与分类的落库(新增 / 修改 / 删除 / 拖拽排序)全部由父级(index.vue)决定。
|
||||
* 搜索关键字是纯 UI 状态,留在本组件;展开键需由父级在加载后统一展开,故以
|
||||
* v-model:expanded-keys 暴露。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Pencil, Plus, RefreshCw, Search, Trash2 } from '@lucide/vue'
|
||||
import { Button, Input, Spin } from '@/components/ui'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
import { collectAllKeys, filterTreeByText } from '@/utils/tree'
|
||||
import { ALL_CATEGORY_KEY } from './utils'
|
||||
|
||||
const props = defineProps({
|
||||
/** 分类树(b_othercompany_bmfl),父级持有并负责加载 / 保存 */
|
||||
treeData: { type: Array, required: true },
|
||||
/** 树数据加载中 */
|
||||
loading: { type: Boolean, default: false },
|
||||
/** 当前选中的分类编码;「全部分类」时为虚拟根 key */
|
||||
selectedKey: { type: [String, Number], default: '' },
|
||||
/** 展开的树节点 key(v-model:父级加载分类后统一展开) */
|
||||
expandedKeys: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:expandedKeys',
|
||||
'select',
|
||||
'add',
|
||||
'edit',
|
||||
'delete',
|
||||
'refresh',
|
||||
'drop',
|
||||
])
|
||||
|
||||
/** 搜索关键字(纯 UI 状态) */
|
||||
const keyword = ref('')
|
||||
|
||||
/** 分类没有 b_code,匹配「名称 + 编码」两列(编码即 b_id) */
|
||||
const filteredTree = computed(() =>
|
||||
filterTreeByText(props.treeData, keyword.value, ['b_name', 'b_id']),
|
||||
)
|
||||
|
||||
// 搜索时展开全部(保证命中节点可见),清空时收起
|
||||
watch(keyword, (kw) => {
|
||||
emit('update:expandedKeys', kw.trim() ? collectAllKeys(props.treeData) : [])
|
||||
})
|
||||
|
||||
/** 「全部分类」虚拟根(node 为 null 表示右键点在空白处,一并按根处理) */
|
||||
function isRootNode(node) {
|
||||
return !node || node.isFmsRoot
|
||||
}
|
||||
|
||||
/**
|
||||
* 右键菜单项数据源:根节点只能新增顶级分类,普通节点可新增子分类 / 修改 / 删除。
|
||||
*/
|
||||
function buildContextMenu(node) {
|
||||
if (isRootNode(node)) {
|
||||
return [{ key: 'add', label: '新增分类', icon: Plus }]
|
||||
}
|
||||
return [
|
||||
{ key: 'add', label: '新增子分类', icon: Plus },
|
||||
{ divider: true },
|
||||
{ key: 'edit', label: '修改分类', icon: Pencil },
|
||||
{ key: 'delete', label: '删除', icon: Trash2, danger: true },
|
||||
]
|
||||
}
|
||||
|
||||
function onContextMenuClick({ item, node }) {
|
||||
const parentId = isRootNode(node) ? null : String(node.b_id)
|
||||
if (item.key === 'add') emit('add', { parentId })
|
||||
else if (item.key === 'edit') emit('edit', node)
|
||||
else if (item.key === 'delete') emit('delete', node)
|
||||
}
|
||||
|
||||
/** 工具栏新增:挂到当前选中分类下;选中「全部分类」或未选中时建为顶级分类 */
|
||||
function onToolbarAdd() {
|
||||
const selected = String(props.selectedKey ?? '')
|
||||
emit('add', {
|
||||
parentId: !selected || selected === ALL_CATEGORY_KEY ? null : selected,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="oc-tree-panel">
|
||||
<div class="oc-tree-header">
|
||||
<Input v-model="keyword" placeholder="搜索分类" allow-clear class="oc-tree-search">
|
||||
<template #prefix><Search :size="14" class="oc-tree-search__icon" /></template>
|
||||
</Input>
|
||||
<div class="oc-tree-header__actions">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="oc-tree-header__icon-btn"
|
||||
title="刷新分类"
|
||||
aria-label="刷新分类"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCw :size="14" />
|
||||
</Button>
|
||||
<Button
|
||||
type="ghost"
|
||||
class="oc-tree-header__icon-btn"
|
||||
title="新增分类"
|
||||
aria-label="新增分类"
|
||||
@click="onToolbarAdd"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oc-tree-panel__scroll">
|
||||
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
|
||||
<FmsTree
|
||||
show-root
|
||||
root-title="全部分类"
|
||||
:tree-data="filteredTree"
|
||||
:selected-keys="selectedKey ? [String(selectedKey)] : []"
|
||||
:expanded-keys="expandedKeys"
|
||||
:context-menu="buildContextMenu"
|
||||
:draggable="!keyword.trim()"
|
||||
@select="(keys, info) => emit('select', keys, info)"
|
||||
@update:expanded-keys="(keys) => emit('update:expandedKeys', keys)"
|
||||
@context-menu-click="onContextMenuClick"
|
||||
@drop="(payload) => emit('drop', payload)"
|
||||
>
|
||||
<template #title="{ node, title }">
|
||||
<span
|
||||
class="oc-tree-node__name"
|
||||
:class="{ 'is-disabled': !node.isFmsRoot && Number(node.b_canuse) !== 1 }"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
</template>
|
||||
</FmsTree>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="oc-tree-panel__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* 灰底画布上的白卡:白底 + 圆角 + 阴影,与右侧列表卡用灰缝分隔 */
|
||||
.oc-tree-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.oc-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.oc-tree-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oc-tree-search__icon {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.oc-tree-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.oc-tree-header__icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
background: var(--fms-card);
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--fms-secondary);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.oc-tree-panel__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.oc-tree-panel__scroll :deep(.tree) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
/* 分类名称撑满剩余宽度,停用置灰 */
|
||||
.oc-tree-node__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.is-disabled {
|
||||
color: var(--fms-text-disabled, #9ca3af);
|
||||
}
|
||||
}
|
||||
|
||||
.oc-tree-panel__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(255 255 255 / 60%);
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,9 @@
|
||||
/**
|
||||
* 往来单位(合作伙伴)详情 —— 新增与编辑都在本页完成。
|
||||
*
|
||||
* 路由契约:`/base/othercompany/detail/:id`
|
||||
* - `id = 'new'` 表示新建,其余为 b_othercompany.b_id;
|
||||
* 路由契约:`/base/othercompany/detail`
|
||||
* - 不带参数(`route.query.id` 为空)表示新建;
|
||||
* - `?id=xxx` 表示编辑 b_othercompany.b_id = xxx;
|
||||
* - 顶部工具栏的保存 / 重置 / 删除、主表与子表(银行账号等)均尚未实现。
|
||||
*/
|
||||
</script>
|
||||
|
||||
@@ -2,69 +2,160 @@
|
||||
/**
|
||||
* 往来单位(合作伙伴)—— 左侧分类树 + 右侧模块列表。
|
||||
*
|
||||
* 数据来源:
|
||||
* - 左树:b_othercompany_bmfl(邻接表树,b_parent_id 是权威关系,b_path 为派生路径)
|
||||
* - 右表:模块 v_b_othercompany(查询栏 / 列 / 分页全部由 FmsModuleListPage 按模块配置驱动)
|
||||
*
|
||||
* 页面负责三件事:
|
||||
* - 把左侧选中的分类转成列表的固定查询条件(含全部下级分类);
|
||||
* - 列表列 b_cate_id 显示分类名称而不是节点编码;
|
||||
* - 左侧分类树的维护:新增 / 修改 / 删除 / 刷新 / 拖拽调层级与顺序
|
||||
* (CategoryTreePanel + CategoryEditModal,写 b_othercompany_bmfl 后重载整棵树)。
|
||||
*
|
||||
* 分类行的 b_depth / b_path 是 b_parent_id 的派生字段,统一由 ./utils.js 派生;
|
||||
* 往来单位的新增与编辑在详情页(base-othercompany-detail)完成,本页只做跳转。
|
||||
* 左树是 b_othercompany_bmfl(b_parent_id 是权威关系,b_depth / b_path 为派生字段),
|
||||
* 右表由 FmsModuleListPage 按模块 v_b_othercompany 的配置驱动。页面负责三件事:
|
||||
* 选中分类 → 固定查询条件(含全部下级)、b_cate_id 列显示分类名称、分类树的维护
|
||||
* (新增 / 修改 / 删除 / 刷新 / 拖拽调层级与顺序)。
|
||||
* 新增与编辑往来单位在详情页(base-othercompany-detail)完成,本页只做跳转。
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message, Modal, Splitter, SplitterPanel } from '@/components/ui'
|
||||
import { Pencil, Plus, Search, Trash2 } from '@lucide/vue'
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
FormItem,
|
||||
Input,
|
||||
Message,
|
||||
Modal,
|
||||
Spin,
|
||||
Splitter,
|
||||
SplitterPanel,
|
||||
} from '@/components/ui'
|
||||
import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
import { escapeSqlText } from '@/components/fms-module-common/queryUtils'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
import { loadDataApi, pageDataApi, saveObjectApi } from '@/services/api'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
import { findTreeNode } from '@/utils/tree'
|
||||
import CategoryTreePanel from './CategoryTreePanel.vue'
|
||||
import CategoryEditModal from './CategoryEditModal.vue'
|
||||
import {
|
||||
ALL_CATEGORY_KEY,
|
||||
addRowUpdate,
|
||||
applyCategoryPosition,
|
||||
buildCategoryTree,
|
||||
findNodePosition,
|
||||
hasChildCategory,
|
||||
rebuildTreePositions,
|
||||
renumberSiblings,
|
||||
} from './utils'
|
||||
import { buildTree, collectAllKeys, filterTreeByText, findTreeNode, sortTree } from '@/utils/tree'
|
||||
|
||||
/** 右侧列表使用的模块编码(s_module.b_id / s_field.b_module_id) */
|
||||
const MODULE_CODE = 'v_b_othercompany'
|
||||
/** 左侧分类树表 */
|
||||
const CATEGORY_TABLE = 'b_othercompany_bmfl'
|
||||
const MODULE_CODE = 'v_b_othercompany' // 右侧列表模块
|
||||
const CATEGORY_TABLE = 'b_othercompany_bmfl' // 左侧分类树表
|
||||
/** 必须与 FmsTree 的 rootKey 默认值一致 */
|
||||
const ALL_CATEGORY_KEY = '__fms_tree_root__'
|
||||
|
||||
/* ===================== 分类树纯函数 ===================== */
|
||||
|
||||
/**
|
||||
* 必须在浅拷贝上构建:buildTree 会就地往行上挂 children,
|
||||
* 直接构建会累积出重复子节点、并污染唯一数据源 categories。
|
||||
*/
|
||||
function buildCategoryTree(rows) {
|
||||
const tree = buildTree((rows || []).map((row) => ({ ...row })))
|
||||
sortTree(tree)
|
||||
return tree
|
||||
}
|
||||
|
||||
/**
|
||||
* 派生 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
|
||||
* b_path 非空且无默认值,新增时必须随行写入。
|
||||
*/
|
||||
function applyCategoryPosition(row, parentNode) {
|
||||
row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
|
||||
row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
|
||||
}
|
||||
|
||||
/** 同级下一个排序号(最大 b_xh + 10,无同级时取 10) */
|
||||
function nextSiblingXh(rows, parentId) {
|
||||
const normalizedParentId = parentId == null ? null : String(parentId)
|
||||
let maxXh = 0
|
||||
for (const row of rows || []) {
|
||||
const rowParentId = row.b_parent_id == null ? null : String(row.b_parent_id)
|
||||
if (rowParentId !== normalizedParentId) continue
|
||||
maxXh = Math.max(maxXh, Number(row.b_xh) || 0)
|
||||
}
|
||||
return maxXh + 10
|
||||
}
|
||||
|
||||
/** 有子分类的分类不允许删除 */
|
||||
function hasChildCategory(rows, id) {
|
||||
const targetId = String(id)
|
||||
return (rows || []).some((row) => row.b_parent_id != null && String(row.b_parent_id) === targetId)
|
||||
}
|
||||
|
||||
/** 查找节点所在的父数组与索引 */
|
||||
function findNodePosition(nodes, id) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||||
if (nodes[i].children) {
|
||||
const found = findNodePosition(nodes[i].children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 同一 b_id 的字段合并进一行,避免同节点产生多条 update */
|
||||
function addRowUpdate(updatedRows, row) {
|
||||
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
|
||||
if (existing) Object.assign(existing, row)
|
||||
else updatedRows.push({ ...row })
|
||||
}
|
||||
|
||||
/** 重排兄弟 b_xh(10 起步、步长 10),只收集实际变化的行 */
|
||||
function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((sibling, index) => {
|
||||
const nextXh = (index + 1) * 10
|
||||
if (Number(sibling.b_xh) !== nextXh) {
|
||||
sibling.b_xh = nextXh
|
||||
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 整树重建 b_depth / b_path(b_parent_id 为权威关系),只收集实际变化的行 */
|
||||
function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
|
||||
for (const node of nodes) {
|
||||
const path = parentPath ? `${parentPath}${node.b_id}/` : `/${node.b_id}/`
|
||||
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
|
||||
node.b_depth = depth
|
||||
node.b_path = path
|
||||
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
|
||||
}
|
||||
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 权限 ================= */
|
||||
const permissionStore = usePermissionStore()
|
||||
const canCreateRow = computed(() => permissionStore.canPower(MODULE_CODE, 'create'))
|
||||
const canDeleteRow = computed(() => permissionStore.canPower(MODULE_CODE, 'delete'))
|
||||
|
||||
const listRef = ref(null)
|
||||
|
||||
/* ================= 分类树 ================= */
|
||||
/* ================= 分类树:状态与加载 ================= */
|
||||
const categories = ref([])
|
||||
const categoryLoading = ref(false)
|
||||
const selectedCategoryKey = ref(ALL_CATEGORY_KEY)
|
||||
const expandedCategoryKeys = ref([])
|
||||
const categoryKeyword = ref('')
|
||||
|
||||
const categoryById = computed(
|
||||
() => new Map(categories.value.map((item) => [String(item.b_id), item])),
|
||||
)
|
||||
|
||||
/** 邻接表 → 树;顺序由 b_xh 决定。树是 categories 的派生视图(在浅拷贝上构建) */
|
||||
/** 树是 categories 的派生视图(在浅拷贝上构建) */
|
||||
const categoryTree = computed(() => buildCategoryTree(categories.value))
|
||||
|
||||
/** 分类没有 b_code,匹配「名称 + 编码」两列 */
|
||||
const filteredCategoryTree = computed(() =>
|
||||
filterTreeByText(categoryTree.value, categoryKeyword.value, ['b_name', 'b_id']),
|
||||
)
|
||||
|
||||
// 搜索时展开全部(保证命中节点可见),清空时收起
|
||||
watch(categoryKeyword, (kw) => {
|
||||
expandedCategoryKeys.value = kw.trim() ? collectAllKeys(categoryTree.value) : []
|
||||
})
|
||||
|
||||
async function loadCategories() {
|
||||
categoryLoading.value = true
|
||||
try {
|
||||
const response = await loadDataApi(CATEGORY_TABLE, '', 'b_path ASC, b_xh ASC, b_id ASC')
|
||||
// 只加载启用分类(未启用的不展示)
|
||||
const response = await loadDataApi(
|
||||
CATEGORY_TABLE,
|
||||
'b_canuse = 1',
|
||||
'b_path ASC, b_xh ASC, b_id ASC',
|
||||
)
|
||||
categories.value = response.data || []
|
||||
|
||||
// 选中的分类已被删除时回到「全部」
|
||||
@@ -76,7 +167,7 @@ async function loadCategories() {
|
||||
selectedCategoryKey.value = ALL_CATEGORY_KEY
|
||||
}
|
||||
|
||||
// 展开所有含子节点的分类;保留用户已手动展开的节点
|
||||
// 展开所有含子节点的分类,并保留用户已手动展开的
|
||||
const parentKeys = categories.value
|
||||
.map((item) => (item.b_parent_id == null ? '' : String(item.b_parent_id)))
|
||||
.filter(Boolean)
|
||||
@@ -96,7 +187,7 @@ function onCategorySelect(keys, info) {
|
||||
if (keys.length) selectedCategoryKey.value = String(keys[0])
|
||||
}
|
||||
|
||||
/** 收集某个分类及其全部下级分类的节点编码 */
|
||||
/** 收集某个分类及其全部下级分类的编码 */
|
||||
function collectDescendantIds(rootId) {
|
||||
const childrenByParent = new Map()
|
||||
for (const item of categories.value) {
|
||||
@@ -118,11 +209,7 @@ function collectDescendantIds(rootId) {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类 → 列表固定查询条件。
|
||||
* b_cate_id 是 varchar,条件值用 varchar 字面量(不加 N 前缀),避免列被隐式转成
|
||||
* nvarchar 而用不上 ix_b_othercompany_cate。
|
||||
*/
|
||||
/** b_cate_id 是 varchar:条件值不加 N 前缀,避免列被隐式转成 nvarchar 而用不上索引 */
|
||||
const categoryFilterCondition = computed(() => {
|
||||
if (selectedCategoryKey.value === ALL_CATEGORY_KEY) return ''
|
||||
const ids = collectDescendantIds(selectedCategoryKey.value)
|
||||
@@ -131,7 +218,7 @@ const categoryFilterCondition = computed(() => {
|
||||
return `[b_cate_id] IN (${literals.join(', ')})`
|
||||
})
|
||||
|
||||
/* ================= 列扩展:分类编码显示为分类名称 ================= */
|
||||
/* ================= 列扩展:b_cate_id 显示分类名称 ================= */
|
||||
const columnExtensions = computed(() => ({
|
||||
b_cate_id: {
|
||||
formatter: ({ value }) => {
|
||||
@@ -141,12 +228,50 @@ const columnExtensions = computed(() => ({
|
||||
},
|
||||
}))
|
||||
|
||||
/* ================= 分类树:右键菜单与工具栏 ================= */
|
||||
|
||||
/** node 为 null 表示右键点在空白处,一并按虚拟根处理 */
|
||||
function isRootCategoryNode(node) {
|
||||
return !node || node.isFmsRoot
|
||||
}
|
||||
|
||||
/** 根节点只能新增顶级分类 */
|
||||
function buildCategoryContextMenu(node) {
|
||||
if (isRootCategoryNode(node)) {
|
||||
return [{ key: 'add', label: '新增分类', icon: Plus }]
|
||||
}
|
||||
return [
|
||||
{ key: 'add', label: '新增子分类', icon: Plus },
|
||||
{ divider: true },
|
||||
{ key: 'edit', label: '修改分类', icon: Pencil },
|
||||
{ key: 'delete', label: '删除', icon: Trash2, danger: true },
|
||||
]
|
||||
}
|
||||
|
||||
function onCategoryMenuClick({ item, node }) {
|
||||
if (item.key === 'add') {
|
||||
onCategoryAdd({ parentId: isRootCategoryNode(node) ? null : String(node.b_id) })
|
||||
} else if (item.key === 'edit') {
|
||||
onCategoryEdit(node)
|
||||
} else if (item.key === 'delete') {
|
||||
void onCategoryDelete(node)
|
||||
}
|
||||
}
|
||||
|
||||
/** 工具栏新增挂到当前选中分类下;选中「全部分类」或未选中时建为顶级 */
|
||||
function onCategoryToolbarAdd() {
|
||||
const selected = String(selectedCategoryKey.value ?? '')
|
||||
onCategoryAdd({
|
||||
parentId: !selected || selected === ALL_CATEGORY_KEY ? null : selected,
|
||||
})
|
||||
}
|
||||
|
||||
/* ================= 新增:跳转详情页 ================= */
|
||||
const router = useRouter()
|
||||
|
||||
/** 详情页按路由参数 :id 区分,'new' 表示新建 */
|
||||
/** 新增:不带参数跳详情页;编辑同页完成,用 ?id=xxx 传主键 */
|
||||
function onCreate() {
|
||||
router.push({ name: 'base-othercompany-detail', params: { id: 'new' } })
|
||||
router.push({ name: 'base-othercompany-detail' })
|
||||
}
|
||||
|
||||
/* ================= 行多选与批量删除 ================= */
|
||||
@@ -159,24 +284,26 @@ const rowSelection = computed(() => ({
|
||||
},
|
||||
}))
|
||||
|
||||
const confirmOpen = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
async function confirmBatchDelete() {
|
||||
deleting.value = true
|
||||
try {
|
||||
await listRef.value?.deleteSelected(selectedRowKeys.value)
|
||||
Message.success('删除成功')
|
||||
selectedRowKeys.value = []
|
||||
confirmOpen.value = false
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
function confirmBatchDelete() {
|
||||
Modal.confirm({
|
||||
title: `确定删除选中的 ${selectedRowKeys.value.length} 条往来单位?`,
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await listRef.value?.deleteSelected(selectedRowKeys.value)
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
throw error // 保持确认框打开,便于重试
|
||||
}
|
||||
Message.success('删除成功')
|
||||
selectedRowKeys.value = []
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ================= 分类维护:新增 / 修改 ================= */
|
||||
/* ================= 分类维护:弹窗与表单 ================= */
|
||||
const categoryModalOpen = ref(false)
|
||||
const categoryModalSaving = ref(false)
|
||||
/** 修改时的分类行;新增时为 null */
|
||||
@@ -184,6 +311,77 @@ const editingCategory = ref(null)
|
||||
/** 新增分类的上级编码;null 表示顶级 */
|
||||
const categoryParentId = ref(null)
|
||||
|
||||
const isCategoryEdit = computed(() => !!editingCategory.value)
|
||||
|
||||
/** 本地草稿:打开时快照,确定时提交 */
|
||||
const categoryDraft = ref({ b_id: '', b_name: '', b_xh: 10 })
|
||||
|
||||
const categoryParentName = computed(() => {
|
||||
if (categoryParentId.value == null || categoryParentId.value === '') return '全部分类'
|
||||
const parent = categories.value.find((row) => String(row.b_id) === String(categoryParentId.value))
|
||||
return parent?.b_name || String(categoryParentId.value)
|
||||
})
|
||||
|
||||
watch(categoryModalOpen, (open) => {
|
||||
if (!open) return
|
||||
if (editingCategory.value) {
|
||||
categoryDraft.value = {
|
||||
b_id: editingCategory.value.b_id ?? '',
|
||||
b_name: editingCategory.value.b_name ?? '',
|
||||
b_xh: Number(editingCategory.value.b_xh) || 0,
|
||||
}
|
||||
return
|
||||
}
|
||||
categoryDraft.value = {
|
||||
b_id: '',
|
||||
b_name: '',
|
||||
b_xh: nextSiblingXh(categories.value, categoryParentId.value),
|
||||
}
|
||||
})
|
||||
|
||||
function setCategoryField(field, value) {
|
||||
categoryDraft.value = { ...categoryDraft.value, [field]: value }
|
||||
}
|
||||
|
||||
const categoryFormRef = ref(null)
|
||||
|
||||
const categoryFormRules = computed(() => ({
|
||||
b_id: [
|
||||
{ required: true, message: '请填写分类编码' },
|
||||
{ pattern: /^[A-Za-z0-9_-]+$/, message: '分类编码只能包含字母、数字、下划线或短横线' },
|
||||
{ max: 150, message: '分类编码长度不能超过 150' },
|
||||
{
|
||||
// b_id 是主键,重复即入库失败;这里提前拦下(修改自身时排除)
|
||||
validator: (value) => {
|
||||
const code = String(value ?? '').trim()
|
||||
if (!code) return true
|
||||
const selfId = String(editingCategory.value?.b_id ?? '')
|
||||
const duplicated = categories.value.some(
|
||||
(row) => String(row.b_id) === code && String(row.b_id) !== selfId,
|
||||
)
|
||||
return duplicated ? '分类编码已存在' : true
|
||||
},
|
||||
},
|
||||
],
|
||||
b_name: [
|
||||
{ required: true, message: '请填写分类名称' },
|
||||
{ max: 200, message: '分类名称长度不能超过 200' },
|
||||
],
|
||||
}))
|
||||
|
||||
async function onCategoryOk() {
|
||||
const errors = (await categoryFormRef.value?.validateAll()) ?? []
|
||||
if (errors.length) {
|
||||
Message.warning('请修正表单中标红的错误项')
|
||||
return
|
||||
}
|
||||
void onCategorySubmit({
|
||||
b_id: String(categoryDraft.value.b_id ?? '').trim(),
|
||||
b_name: String(categoryDraft.value.b_name ?? '').trim(),
|
||||
b_xh: Number(categoryDraft.value.b_xh) || 0,
|
||||
})
|
||||
}
|
||||
|
||||
function onCategoryAdd({ parentId }) {
|
||||
editingCategory.value = null
|
||||
categoryParentId.value = parentId == null ? null : String(parentId)
|
||||
@@ -201,13 +399,10 @@ function onCategoryEdit(node) {
|
||||
categoryModalOpen.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* 弹窗确定:新增补 b_parent_id / b_depth / b_path 后 insert;修改只提交名称与排序号
|
||||
* (分类编码是主键,创建后不可改)。落库成功后重载整棵树,保持「categories 是唯一数据源」。
|
||||
*/
|
||||
/** 新增补 b_depth / b_path 后 insert;修改只提交名称与排序号(编码是主键,创建后不可改) */
|
||||
async function onCategorySubmit(payload) {
|
||||
if (categoryModalSaving.value) return
|
||||
const isEdit = !!editingCategory.value
|
||||
const isEdit = isCategoryEdit.value
|
||||
categoryModalSaving.value = true
|
||||
try {
|
||||
if (isEdit) {
|
||||
@@ -226,7 +421,6 @@ async function onCategorySubmit(payload) {
|
||||
b_xh: payload.b_xh,
|
||||
b_canuse: 1,
|
||||
}
|
||||
// b_depth / b_path 是 b_parent_id 的派生字段;b_path 非空且无默认值,必须随行写入
|
||||
const parentNode = categoryParentId.value
|
||||
? findTreeNode(categoryTree.value, categoryParentId.value)
|
||||
: null
|
||||
@@ -244,31 +438,22 @@ async function onCategorySubmit(payload) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 删除分类(两条守卫:有子分类 / 分类下有往来单位) ================= */
|
||||
const categoryDeleteTarget = ref(null)
|
||||
const categoryDeleting = ref(false)
|
||||
/* ================= 删除分类(有子分类 / 分类下有往来单位 两条守卫) ================= */
|
||||
/** 守卫查询进行中:阻止右键重复触发 */
|
||||
const categoryChecking = ref(false)
|
||||
|
||||
/** 确认框显隐由「待删分类」派生:关闭即清空待删项,避免残留 */
|
||||
const categoryDeleteOpen = computed({
|
||||
get: () => !!categoryDeleteTarget.value,
|
||||
set: (open) => {
|
||||
if (!open) categoryDeleteTarget.value = null
|
||||
},
|
||||
})
|
||||
|
||||
/** 删除入口:先做同步/异步守卫,全部通过才弹确认框 */
|
||||
async function onCategoryDelete(node) {
|
||||
const category = categoryById.value.get(String(node?.b_id ?? ''))
|
||||
if (!category || categoryDeleting.value) return
|
||||
if (!category || categoryChecking.value) return
|
||||
if (hasChildCategory(categories.value, category.b_id)) {
|
||||
Message.warning('该分类下还有子分类,请先删除子分类')
|
||||
return
|
||||
}
|
||||
|
||||
categoryDeleting.value = true
|
||||
categoryChecking.value = true
|
||||
let hasData = false
|
||||
try {
|
||||
// 分类下还挂着往来单位时不允许删除。b_cate_id 是 varchar,条件值用 varchar 字面量
|
||||
// (不加 N 前缀),避免列被隐式转成 nvarchar 而用不上 ix_b_othercompany_cate
|
||||
// b_cate_id 是 varchar:条件值不加 N 前缀,避免列被隐式转成 nvarchar 而用不上索引
|
||||
const response = await pageDataApi({
|
||||
viewName: MODULE_CODE,
|
||||
searchColumns: ['b_id'],
|
||||
@@ -276,37 +461,36 @@ async function onCategoryDelete(node) {
|
||||
pageNo: 1,
|
||||
pageSize: 1,
|
||||
})
|
||||
const total = Number(response.data?.total ?? response.data?.rows?.length ?? 0)
|
||||
if (total > 0) {
|
||||
Message.warning('该分类下还有往来单位数据,不能删除')
|
||||
return
|
||||
}
|
||||
hasData = Number(response.data?.total ?? response.data?.rows?.length ?? 0) > 0
|
||||
} catch (error) {
|
||||
Message.error('分类数据检查失败:' + (error?.message || error))
|
||||
return
|
||||
} finally {
|
||||
categoryDeleting.value = false
|
||||
categoryChecking.value = false
|
||||
}
|
||||
if (hasData) {
|
||||
Message.warning('该分类下还有往来单位数据,不能删除')
|
||||
return
|
||||
}
|
||||
|
||||
categoryDeleteTarget.value = category
|
||||
}
|
||||
|
||||
async function confirmCategoryDelete() {
|
||||
const category = categoryDeleteTarget.value
|
||||
if (!category || categoryDeleting.value) return
|
||||
categoryDeleting.value = true
|
||||
try {
|
||||
await saveObjectApi([
|
||||
{ table: CATEGORY_TABLE, key_field: 'b_id', deletes: [{ b_id: category.b_id }] },
|
||||
])
|
||||
Message.success('分类已删除')
|
||||
categoryDeleteTarget.value = null
|
||||
await loadCategories()
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
} finally {
|
||||
categoryDeleting.value = false
|
||||
}
|
||||
Modal.confirm({
|
||||
title: `确定删除分类「${category.b_name || category.b_id}」?`,
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await saveObjectApi([
|
||||
{ table: CATEGORY_TABLE, key_field: 'b_id', deletes: [{ b_id: category.b_id }] },
|
||||
])
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
throw error // 保持确认框打开,便于重试
|
||||
}
|
||||
Message.success('分类已删除')
|
||||
await loadCategories()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/* ================= 拖拽排序(同级调序 / 跨级换父) ================= */
|
||||
@@ -315,8 +499,7 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
|
||||
const dropId = String(dropKey)
|
||||
if (dragId === dropId) return
|
||||
|
||||
// 在树的临时副本上算 diff:树只是 categories 的派生视图,
|
||||
// 就地改会与唯一数据源脱节,故一律「算差异 → 落库 → 重载」
|
||||
// 树只是 categories 的派生视图,所以一贯「在副本上算差异 → 落库 → 重载」
|
||||
const tree = buildCategoryTree(categories.value)
|
||||
const dragPos = findNodePosition(tree, dragId)
|
||||
const dropPos = findNodePosition(tree, dropId)
|
||||
@@ -324,7 +507,7 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
|
||||
|
||||
const dragNode = dragPos.list[dragPos.index]
|
||||
const dropNode = dropPos.list[dropPos.index]
|
||||
// 落点不能是被拖节点自身或它的后代:父子关系成环后树与 b_path 都会失真
|
||||
// 落点不能是被拖节点自身或它的后代:成环后树与 b_path 都会失真
|
||||
if (findTreeNode([dragNode], dropId)) {
|
||||
Message.warning('不能把分类移动到它自己的下级')
|
||||
return
|
||||
@@ -338,14 +521,12 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
|
||||
const renumberSets = new Set()
|
||||
|
||||
if (dropPosition === 'inside') {
|
||||
// 成为落点节点的子节点
|
||||
moved.b_parent_id = dropNode.b_id
|
||||
if (!dropNode.children) dropNode.children = []
|
||||
dropNode.children.push(moved)
|
||||
renumberSets.add(dropNode.children)
|
||||
renumberSets.add(dragPos.list)
|
||||
} else {
|
||||
// 同级插入:落点节点前 / 后
|
||||
moved.b_parent_id = dropParentId
|
||||
const targetList = dropPos.list
|
||||
let targetIndex = dropPos.index + (dropPosition === 'after' ? 1 : 0)
|
||||
@@ -356,10 +537,9 @@ async function onCategoryDrop({ dragKey, dropKey, dropPosition }) {
|
||||
}
|
||||
|
||||
renumberSets.forEach((list) => renumberSiblings(list, updatedRows))
|
||||
// 父子关系变化后整树重建 b_depth / b_path,仅收集实际变化的行
|
||||
rebuildTreePositions(tree, updatedRows)
|
||||
// b_parent_id 是权威关系,必须与本节点的派生字段同批落库:
|
||||
// renumberSiblings / rebuildTreePositions 只收集实际变化的派生字段,都不负责父级列本身
|
||||
// b_parent_id 是权威关系,必须与派生字段同批落库:
|
||||
// 上面两个函数只收集变化的派生字段,都不负责父级列本身
|
||||
if (String(moved.b_parent_id ?? '') !== String(movedParentId ?? '')) {
|
||||
addRowUpdate(updatedRows, { b_id: moved.b_id, b_parent_id: moved.b_parent_id ?? null })
|
||||
}
|
||||
@@ -385,18 +565,53 @@ void loadCategories()
|
||||
<div class="oc-page">
|
||||
<Splitter class="oc-splitter">
|
||||
<SplitterPanel :default-size="248" :min="180" :max="420">
|
||||
<CategoryTreePanel
|
||||
v-model:expanded-keys="expandedCategoryKeys"
|
||||
:tree-data="categoryTree"
|
||||
:loading="categoryLoading"
|
||||
:selected-key="selectedCategoryKey"
|
||||
@select="onCategorySelect"
|
||||
@add="onCategoryAdd"
|
||||
@edit="onCategoryEdit"
|
||||
@delete="(node) => void onCategoryDelete(node)"
|
||||
@refresh="loadCategories"
|
||||
@drop="(payload) => void onCategoryDrop(payload)"
|
||||
/>
|
||||
<aside class="oc-tree-panel">
|
||||
<div class="oc-tree-header">
|
||||
<Input
|
||||
v-model="categoryKeyword"
|
||||
placeholder="搜索分类"
|
||||
allow-clear
|
||||
class="oc-tree-search"
|
||||
>
|
||||
<template #prefix><Search :size="14" class="oc-tree-search__icon" /></template>
|
||||
</Input>
|
||||
<div class="oc-tree-header__actions">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="oc-tree-header__icon-btn"
|
||||
title="新增分类"
|
||||
aria-label="新增分类"
|
||||
@click="onCategoryToolbarAdd"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oc-tree-panel__scroll">
|
||||
<!-- 搜索过滤时禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
|
||||
<FmsTree
|
||||
v-model:expanded-keys="expandedCategoryKeys"
|
||||
show-root
|
||||
root-title="全部分类"
|
||||
:tree-data="filteredCategoryTree"
|
||||
:selected-keys="selectedCategoryKey ? [String(selectedCategoryKey)] : []"
|
||||
:context-menu="buildCategoryContextMenu"
|
||||
:draggable="!categoryKeyword.trim()"
|
||||
@select="onCategorySelect"
|
||||
@context-menu-click="onCategoryMenuClick"
|
||||
@drop="(payload) => void onCategoryDrop(payload)"
|
||||
>
|
||||
<template #title="{ title }">
|
||||
<span class="oc-tree-node__name">{{ title }}</span>
|
||||
</template>
|
||||
</FmsTree>
|
||||
</div>
|
||||
|
||||
<div v-if="categoryLoading" class="oc-tree-panel__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
</aside>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
@@ -409,20 +624,15 @@ void loadCategories()
|
||||
:row-selection="rowSelection"
|
||||
>
|
||||
<template #actions="{ loading }">
|
||||
<Button
|
||||
v-if="canCreateRow"
|
||||
type="primary"
|
||||
:disabled="loading || deleting"
|
||||
@click="onCreate"
|
||||
>
|
||||
<Button v-if="canCreateRow" type="primary" :disabled="loading" @click="onCreate">
|
||||
<Plus :size="14" />
|
||||
新增
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDeleteRow"
|
||||
type="danger"
|
||||
:disabled="loading || deleting || !selectedRowKeys.length"
|
||||
@click="confirmOpen = true"
|
||||
:disabled="loading || !selectedRowKeys.length"
|
||||
@click="confirmBatchDelete"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
@@ -433,51 +643,47 @@ void loadCategories()
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
|
||||
<!-- 分类新增 / 修改:确定先校验再落库 -->
|
||||
<Modal
|
||||
v-model:open="confirmOpen"
|
||||
title="确认"
|
||||
:confirm-loading="deleting"
|
||||
:mask-closable="!deleting"
|
||||
:keyboard="!deleting"
|
||||
>
|
||||
<p class="oc-confirm-text">
|
||||
确定删除选中的 {{ selectedRowKeys.length }} 条往来单位?此操作不可恢复。
|
||||
</p>
|
||||
<template #footer>
|
||||
<Button type="outline" :disabled="deleting" @click="confirmOpen = false">取消</Button>
|
||||
<Button type="danger" :loading="deleting" @click="confirmBatchDelete">删除</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 分类新增 / 修改 -->
|
||||
<CategoryEditModal
|
||||
v-model:open="categoryModalOpen"
|
||||
:editing="editingCategory"
|
||||
:parent-id="categoryParentId"
|
||||
:categories="categories"
|
||||
:saving="categoryModalSaving"
|
||||
@submit="onCategorySubmit"
|
||||
/>
|
||||
|
||||
<!-- 分类删除确认(守卫已在 onCategoryDelete 里过完) -->
|
||||
<Modal
|
||||
v-model:open="categoryDeleteOpen"
|
||||
title="删除分类"
|
||||
:confirm-loading="categoryDeleting"
|
||||
:mask-closable="!categoryDeleting"
|
||||
:keyboard="!categoryDeleting"
|
||||
:title="isCategoryEdit ? '修改分类' : '新增分类'"
|
||||
:width="520"
|
||||
:mask-closable="false"
|
||||
:confirm-loading="categoryModalSaving"
|
||||
@ok="onCategoryOk"
|
||||
>
|
||||
<p class="oc-confirm-text">
|
||||
确定删除分类「{{ categoryDeleteTarget?.b_name || categoryDeleteTarget?.b_id }}」?
|
||||
</p>
|
||||
<template #footer>
|
||||
<Button type="outline" :disabled="categoryDeleting" @click="categoryDeleteTarget = null">
|
||||
取消
|
||||
</Button>
|
||||
<Button type="danger" :loading="categoryDeleting" @click="confirmCategoryDelete">
|
||||
删除
|
||||
</Button>
|
||||
</template>
|
||||
<Form
|
||||
ref="categoryFormRef"
|
||||
:model="categoryDraft"
|
||||
:rules="categoryFormRules"
|
||||
layout="vertical"
|
||||
:gap="16"
|
||||
>
|
||||
<FormItem label="上级分类" name="b_parent_id">
|
||||
<Input :model-value="categoryParentName" disabled />
|
||||
</FormItem>
|
||||
<FormItem label="分类编码" name="b_id" required>
|
||||
<Input
|
||||
:model-value="categoryDraft.b_id"
|
||||
:disabled="isCategoryEdit"
|
||||
placeholder="主键即编码,保存后不可修改"
|
||||
@update:model-value="(v) => setCategoryField('b_id', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="分类名称" name="b_name" required>
|
||||
<Input
|
||||
:model-value="categoryDraft.b_name"
|
||||
@update:model-value="(v) => setCategoryField('b_name', v)"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="排序号" name="b_xh">
|
||||
<Input
|
||||
:model-value="categoryDraft.b_xh"
|
||||
type="number"
|
||||
@update:model-value="(v) => setCategoryField('b_xh', Number(v) || 0)"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -509,9 +715,100 @@ void loadCategories()
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 右侧列表卡:不再套白底。FmsModuleListPage 内部已有 .fms-module-card
|
||||
* (白底 + 圆角 + 阴影),外层再铺白底会把内卡之间的灰缝染色、并让整块区域变成直角白板。
|
||||
* 左侧分类树卡片的样式随 CategoryTreePanel 一起维护。 */
|
||||
/* 灰底画布上的白卡,与右侧列表卡用灰缝分隔 */
|
||||
.oc-tree-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.oc-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.oc-tree-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oc-tree-search__icon {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.oc-tree-header__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.oc-tree-header__icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
background: var(--fms-card);
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 150ms ease,
|
||||
background 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--fms-secondary);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.oc-tree-panel__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.oc-tree-panel__scroll :deep(.tree) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.oc-tree-node__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oc-tree-panel__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(255 255 255 / 60%);
|
||||
}
|
||||
|
||||
/* 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 */
|
||||
.oc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -520,10 +817,4 @@ void loadCategories()
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.oc-confirm-text {
|
||||
margin: 0;
|
||||
line-height: 1.7;
|
||||
color: var(--fms-text);
|
||||
}
|
||||
</style>
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* 往来单位分类树(othercompany)页面级纯函数。
|
||||
* 只放无 Vue 状态依赖的逻辑:建树、位置派生、排序号计算、兄弟重排。
|
||||
* 树本身的通用查找/过滤/插入/移除见 @/utils/tree,不在此重复。
|
||||
*/
|
||||
import { buildTree, sortTree } from '@/utils/tree'
|
||||
|
||||
/**
|
||||
* 「全部分类」虚拟根节点的选中键。
|
||||
* 必须与 FmsTree 的 rootKey 默认值一致,页面与树面板共用同一常量,避免两处硬编码。
|
||||
*/
|
||||
export const ALL_CATEGORY_KEY = '__fms_tree_root__'
|
||||
|
||||
/**
|
||||
* 由扁平分类行构建排序后的分类树。
|
||||
*
|
||||
* 注意:必须在浅拷贝上构建。buildTree 会就地往行对象上挂 children,
|
||||
* 若直接在 categories 的行上构建,同一批行被重复构建时会累积出重复子节点,
|
||||
* 也会把树结构污染回「唯一数据源」categories。
|
||||
*
|
||||
* @param {Array} categories 分类行数组(b_othercompany_bmfl)
|
||||
* @returns {Array} 分类树(节点为浅拷贝)
|
||||
*/
|
||||
export function buildCategoryTree(categories) {
|
||||
const tree = buildTree((categories || []).map((row) => ({ ...row })))
|
||||
sortTree(tree)
|
||||
return tree
|
||||
}
|
||||
|
||||
/**
|
||||
* 按父节点派生分类行的 b_depth / b_path(b_parent_id 是权威关系,二者为派生字段)。
|
||||
* 根节点 b_depth = 0,b_path 形如 /A/B/(节点编码前后均带 /)。
|
||||
* b_path 非空且无默认值,新增时必须随行写入。
|
||||
* @param {Object} row 分类行(就地修改;b_id 必须是最终分类编码)
|
||||
* @param {Object|null} parentNode 父节点;null 表示根节点
|
||||
*/
|
||||
export function applyCategoryPosition(row, parentNode) {
|
||||
row.b_depth = parentNode ? Number(parentNode.b_depth ?? 0) + 1 : 0
|
||||
row.b_path = parentNode ? `${String(parentNode.b_path || '')}${row.b_id}/` : `/${row.b_id}/`
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算同级下一个排序号(同级最大 b_xh + 10,无同级时取 10)。
|
||||
* 新增分类时作为排序号默认值,避免新节点插到同级最前。
|
||||
* @param {Array} categories 分类行数组
|
||||
* @param {string|null} parentId 父节点编码;null 表示顶级
|
||||
* @returns {number} 排序号
|
||||
*/
|
||||
export function nextSiblingXh(categories, parentId) {
|
||||
const normalizedParentId = parentId == null ? null : String(parentId)
|
||||
let maxXh = 0
|
||||
for (const row of categories || []) {
|
||||
const rowParentId = row.b_parent_id == null ? null : String(row.b_parent_id)
|
||||
if (rowParentId !== normalizedParentId) continue
|
||||
maxXh = Math.max(maxXh, Number(row.b_xh) || 0)
|
||||
}
|
||||
return maxXh + 10
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否还有下级分类。有子分类的分类不允许删除。
|
||||
* @param {Array} categories 分类行数组
|
||||
* @param {string|number} id 分类编码
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasChildCategory(categories, id) {
|
||||
const targetId = String(id)
|
||||
return (categories || []).some(
|
||||
(row) => row.b_parent_id != null && String(row.b_parent_id) === targetId,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 在树中查找节点所在的父数组及索引。
|
||||
* @param {Array} nodes 树节点数组
|
||||
* @param {string|number} id 目标节点 ID
|
||||
* @returns {{ list: Array, index: number }|null}
|
||||
*/
|
||||
export function findNodePosition(nodes, id) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
if (String(nodes[i].b_id) === String(id)) return { list: nodes, index: i }
|
||||
if (nodes[i].children) {
|
||||
const found = findNodePosition(nodes[i].children, id)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 向 updatedRows 合并一行变更(同一 b_id 累加字段,避免同节点产生多条 update 请求) */
|
||||
export function addRowUpdate(updatedRows, row) {
|
||||
const existing = updatedRows.find((item) => String(item.b_id) === String(row.b_id))
|
||||
if (existing) Object.assign(existing, row)
|
||||
else updatedRows.push({ ...row })
|
||||
}
|
||||
|
||||
/**
|
||||
* 重排某组兄弟的 b_xh(10 起步、步长 10),并把变化的行收集到 updatedRows。
|
||||
* @param {Array} list 兄弟节点数组(就地修改 b_xh)
|
||||
* @param {Array} updatedRows 变化行收集器
|
||||
*/
|
||||
export function renumberSiblings(list, updatedRows) {
|
||||
list.forEach((sibling, index) => {
|
||||
const nextXh = (index + 1) * 10
|
||||
if (Number(sibling.b_xh) !== nextXh) {
|
||||
sibling.b_xh = nextXh
|
||||
addRowUpdate(updatedRows, { b_id: sibling.b_id, b_xh: nextXh })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归重建分类树的 b_depth / b_path(b_parent_id 为权威关系,二者为派生字段)。
|
||||
* 根节点 b_depth = 0、b_path = /编码/;子节点沿用父级路径拼接,与 applyCategoryPosition 一致。
|
||||
* 仅在值实际变化时收集,避免无谓更新。
|
||||
* @param {Array} nodes 树节点数组(就地修改)
|
||||
* @param {Array} updatedRows 变化行收集器
|
||||
* @param {string} [parentPath] 父节点路径(形如 /A/;根层传空串)
|
||||
* @param {number} [depth] 当前层级
|
||||
*/
|
||||
export function rebuildTreePositions(nodes, updatedRows, parentPath = '', depth = 0) {
|
||||
for (const node of nodes) {
|
||||
const path = parentPath ? `${parentPath}${node.b_id}/` : `/${node.b_id}/`
|
||||
if (Number(node.b_depth) !== depth || String(node.b_path ?? '') !== path) {
|
||||
node.b_depth = depth
|
||||
node.b_path = path
|
||||
addRowUpdate(updatedRows, { b_id: node.b_id, b_depth: depth, b_path: path })
|
||||
}
|
||||
if (node.children?.length) rebuildTreePositions(node.children, updatedRows, path, depth + 1)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,487 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 其他数据(旧「其他数据」子系统的入口)。
|
||||
* 页面结构、权限与编辑入口尚未实现,先占位以便路由与目录结构就位。
|
||||
* 其他数据 —— 左侧分类树 + 右侧可编辑列表。
|
||||
*
|
||||
* 数据模型(s_module 元数据表驱动):
|
||||
* - 根:`b_id = 'base_otherdata'`、`b_module_type = 'module'` 的模块(本页入口);
|
||||
* - 分类:根之下所有 `b_module_type = 'module'` 的子孙(用于组织数据模块的目录节点);
|
||||
* - 数据模块:分类下 `b_module_type = 'data'` 的子孙,右侧列表按其 b_id 加载。
|
||||
*
|
||||
* 分类树是纯导航视图:分类结构(新增 / 修改 / 删除 / 拖拽排序)统一在「模块管理」中维护,
|
||||
* 本页不提供任何分类编辑入口。
|
||||
*
|
||||
* 与旧版(fms-vue-old1)差异:
|
||||
* - 旧版 s_module 使用 `page / data_category / data` 三类型,新版归一为 `module / data / virtual`;
|
||||
* 映射:page → 根 module、data_category → 子 module、data → data。
|
||||
* - 旧版字段名 `b_viewtable / b_savetable / b_keyfield`,新版为 `b_view_table / b_save_table / b_key_field`;
|
||||
* 这些字段由 FmsModuleListPage 内部消化,本页无需感知。
|
||||
*
|
||||
* 权限:
|
||||
* - 分类可见性:无分类维护权限时,只显示「至少含一个可访问数据模块」的分类分支;
|
||||
* - 数据模块可见:canAccess(moduleCode);
|
||||
* - 数据模块行操作:canPower(moduleCode, 'create|update|delete')。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Plus, Save, Search, Trash2 } from '@lucide/vue'
|
||||
import { Button, Message, Modal, Spin, Splitter, SplitterPanel, Input } from '@/components/ui'
|
||||
import { FmsModuleListPage } from '@/components/fms-module-list'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
import { loadDataApi } from '@/services/api'
|
||||
import { usePermissionStore } from '@/stores/permissions'
|
||||
import { buildTree, collectAllKeys, filterTreeByText, sortTree } from '@/utils/tree'
|
||||
|
||||
/** 根模块编码:本页所有分类与数据模块都挂在此模块下 */
|
||||
const ROOT_MODULE_ID = 'base_otherdata'
|
||||
|
||||
/* ================= 权限 ================= */
|
||||
const permissionStore = usePermissionStore()
|
||||
/** 分类维护权限:仅用于可见性裁剪(有维护权限则能看到全部分类结构) */
|
||||
const canManageCategory = computed(
|
||||
() =>
|
||||
permissionStore.canPower(ROOT_MODULE_ID, 'create') ||
|
||||
permissionStore.canPower(ROOT_MODULE_ID, 'update') ||
|
||||
permissionStore.canPower(ROOT_MODULE_ID, 'delete'),
|
||||
)
|
||||
|
||||
/* ================= 模块树:状态与加载 ================= */
|
||||
const modules = ref([])
|
||||
const categoryLoading = ref(false)
|
||||
const selectedCategoryKey = ref('')
|
||||
const selectedModuleCode = ref('')
|
||||
const expandedCategoryKeys = ref([])
|
||||
const categoryKeyword = ref('')
|
||||
|
||||
const moduleById = computed(() => new Map(modules.value.map((item) => [String(item.b_id), item])))
|
||||
|
||||
/**
|
||||
* 树数据:把根之下的所有子孙按 b_parent_id 组织成树。
|
||||
* 必须在浅拷贝上构建:buildTree 会就地往行上挂 children,直接构建会污染 modules。
|
||||
*/
|
||||
const categoryTree = computed(() => {
|
||||
const rows = modules.value.map((row) => ({ ...row, children: undefined }))
|
||||
const tree = buildTree(rows)
|
||||
sortTree(tree)
|
||||
return tree
|
||||
})
|
||||
|
||||
/** 搜索过滤:命中名称 / 编码,保留命中节点的子树 */
|
||||
const filteredCategoryTree = computed(() =>
|
||||
filterTreeByText(categoryTree.value, categoryKeyword.value, ['b_name', 'b_id']),
|
||||
)
|
||||
|
||||
// 搜索时展开全部(保证命中节点可见),清空时回到默认展开集合
|
||||
watch(categoryKeyword, (kw) => {
|
||||
expandedCategoryKeys.value = kw.trim()
|
||||
? collectAllKeys(categoryTree.value)
|
||||
: defaultExpandedKeys()
|
||||
})
|
||||
|
||||
/** 默认展开:无(「全部分类」虚拟根由 FmsTree 默认展开,分组与数据模块保持收起) */
|
||||
function defaultExpandedKeys() {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模块是否为「数据模块」(可作为右侧列表数据源的叶子)。
|
||||
* 虚拟模块(virtual)不作为其他数据页的数据源。
|
||||
*/
|
||||
function isDataModule(item) {
|
||||
return item?.b_module_type === 'data'
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断模块是否属于根模块的子孙(按 b_path 前缀匹配)。
|
||||
* b_path 形如 `/base_otherdata/odc_123/b_some_data/`,前缀匹配即覆盖所有层级。
|
||||
*/
|
||||
function isDescendantOfRoot(item, rootPath) {
|
||||
if (!rootPath) return false
|
||||
const path = String(item.b_path || '')
|
||||
return path.startsWith(rootPath) && String(item.b_id) !== ROOT_MODULE_ID
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载模块树。
|
||||
* 步骤:拉全量启用模块 → 定位根 → 按 b_path 前缀过滤子孙 → 按权限裁剪可见集合。
|
||||
*/
|
||||
async function loadCategories() {
|
||||
categoryLoading.value = true
|
||||
try {
|
||||
// 权限数据可能尚未加载(首屏直接进入本页时),先确保到位
|
||||
await permissionStore.load()
|
||||
const response = await loadDataApi('s_module', 'b_canuse = 1', 'b_xh ASC, b_id ASC')
|
||||
const all = response.data || []
|
||||
|
||||
const root = all.find(
|
||||
(item) => String(item.b_id) === ROOT_MODULE_ID && item.b_module_type === 'module',
|
||||
)
|
||||
const rootPath = String(root?.b_path || '')
|
||||
|
||||
// 根之下的所有子孙(module + data + virtual,virtual 在渲染时过滤)
|
||||
const scoped = rootPath ? all.filter((item) => isDescendantOfRoot(item, rootPath)) : []
|
||||
const scopedById = new Map(scoped.map((item) => [String(item.b_id), item]))
|
||||
|
||||
// 权限裁剪:无分类维护权限时,只保留「至少含一个可访问数据模块」的分类分支
|
||||
const visibleIds = new Set()
|
||||
for (const item of scoped) {
|
||||
if (!isDataModule(item) || !permissionStore.canAccess(String(item.b_id))) continue
|
||||
let current = item
|
||||
while (current) {
|
||||
const currentId = String(current.b_id)
|
||||
if (visibleIds.has(currentId)) break
|
||||
visibleIds.add(currentId)
|
||||
const parentId = current.b_parent_id == null ? '' : String(current.b_parent_id)
|
||||
current = parentId ? scopedById.get(parentId) : null
|
||||
}
|
||||
}
|
||||
|
||||
// 只展示启用项(未启用的不进树);无分类维护权限时再按可见性裁剪
|
||||
modules.value = scoped.filter(
|
||||
(item) =>
|
||||
item.b_module_type !== 'virtual' &&
|
||||
Number(item.b_canuse) === 1 &&
|
||||
(canManageCategory.value || visibleIds.has(String(item.b_id))),
|
||||
)
|
||||
|
||||
// 选中的分类已被删除 / 已不可见时清空选择
|
||||
const existingKeys = new Set(modules.value.map((item) => String(item.b_id)))
|
||||
if (selectedCategoryKey.value && !existingKeys.has(selectedCategoryKey.value)) {
|
||||
selectedCategoryKey.value = ''
|
||||
selectedModuleCode.value = ''
|
||||
} else {
|
||||
// 重新校验当前选中的数据模块是否仍可访问
|
||||
applySelectedModule(selectedCategoryKey.value)
|
||||
}
|
||||
|
||||
// 默认只展开根模块(保留用户已手动展开的分组)
|
||||
const defaults = defaultExpandedKeys()
|
||||
expandedCategoryKeys.value = [...new Set([...expandedCategoryKeys.value, ...defaults])]
|
||||
} catch (error) {
|
||||
Message.error('分类加载失败:' + (error?.message || error))
|
||||
} finally {
|
||||
categoryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据选中的树节点 key 更新右侧数据模块编码。
|
||||
* 分类节点(module)不切换数据源,只更新视觉选中;数据模块节点(data)切换数据源。
|
||||
*/
|
||||
function applySelectedModule(key) {
|
||||
selectedCategoryKey.value = key ? String(key) : ''
|
||||
if (!key) {
|
||||
selectedModuleCode.value = ''
|
||||
return
|
||||
}
|
||||
const node = moduleById.value.get(String(key))
|
||||
if (isDataModule(node) && permissionStore.canAccess(String(node.b_id))) {
|
||||
selectedModuleCode.value = String(node.b_id)
|
||||
} else {
|
||||
selectedModuleCode.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function onCategorySelect(keys, info) {
|
||||
// 点「全部分类」虚拟根:清空右侧数据源
|
||||
if (info?.node?.isFmsRoot) {
|
||||
applySelectedModule('')
|
||||
return
|
||||
}
|
||||
const key = String(info?.node?.b_id ?? keys[0] ?? '')
|
||||
if (!key) return
|
||||
|
||||
const node = moduleById.value.get(key)
|
||||
// 点击分类:切换展开状态(数据模块是叶子,无展开可言)
|
||||
if (node?.b_module_type === 'module') {
|
||||
const expanded = expandedCategoryKeys.value.map(String)
|
||||
const isExpanded = expanded.includes(key)
|
||||
expandedCategoryKeys.value = isExpanded ? expanded.filter((k) => k !== key) : [...expanded, key]
|
||||
}
|
||||
applySelectedModule(key)
|
||||
}
|
||||
|
||||
/* ================= 右侧数据列表 ================= */
|
||||
const listRef = ref(null)
|
||||
const selectedRowKeys = ref([])
|
||||
const dirtyCount = ref(0)
|
||||
const editableSaving = ref(false)
|
||||
|
||||
const canCreateRow = computed(() => permissionStore.canPower(selectedModuleCode.value, 'create'))
|
||||
const canUpdateRow = computed(() => permissionStore.canPower(selectedModuleCode.value, 'update'))
|
||||
const canDeleteRow = computed(() => permissionStore.canPower(selectedModuleCode.value, 'delete'))
|
||||
|
||||
const rowSelection = computed(() => ({
|
||||
selectedRowKeys: selectedRowKeys.value,
|
||||
onChange: (keys) => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
}))
|
||||
|
||||
// 切换数据模块时清空选中与脏计数,避免跨模块误操作
|
||||
watch(selectedModuleCode, () => {
|
||||
selectedRowKeys.value = []
|
||||
dirtyCount.value = 0
|
||||
})
|
||||
|
||||
async function addNewRow() {
|
||||
if (!canCreateRow.value) return
|
||||
await listRef.value?.addRow?.()
|
||||
}
|
||||
|
||||
async function saveEditableChanges() {
|
||||
if (!canUpdateRow.value || editableSaving.value) return
|
||||
if (dirtyCount.value <= 0) {
|
||||
Message.info('没有需要保存的修改')
|
||||
return
|
||||
}
|
||||
editableSaving.value = true
|
||||
try {
|
||||
await listRef.value?.saveChanges?.()
|
||||
dirtyCount.value = 0
|
||||
} finally {
|
||||
editableSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBatchDelete() {
|
||||
const keys = [...selectedRowKeys.value]
|
||||
if (!keys.length) return
|
||||
Modal.confirm({
|
||||
title: `确定删除选中的 ${keys.length} 条记录?`,
|
||||
content: '此操作不可恢复。',
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
editableSaving.value = true
|
||||
try {
|
||||
await listRef.value?.deleteSelected(keys)
|
||||
} catch (error) {
|
||||
Message.error('删除失败:' + (error?.message || error))
|
||||
throw error
|
||||
} finally {
|
||||
editableSaving.value = false
|
||||
}
|
||||
Message.success('删除成功')
|
||||
selectedRowKeys.value = []
|
||||
dirtyCount.value = 0
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
void loadCategories()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="otherdata-page" />
|
||||
<div class="od-page">
|
||||
<Splitter class="od-splitter">
|
||||
<SplitterPanel :default-size="260" :min="200" :max="420">
|
||||
<aside class="od-tree-panel">
|
||||
<div class="od-tree-header">
|
||||
<Input
|
||||
v-model="categoryKeyword"
|
||||
placeholder="搜索分类 / 数据"
|
||||
allow-clear
|
||||
class="od-tree-search"
|
||||
>
|
||||
<template #prefix><Search :size="14" class="od-tree-search__icon" /></template>
|
||||
</Input>
|
||||
</div>
|
||||
|
||||
<div class="od-tree-panel__scroll">
|
||||
<!-- 搜索过滤时树是浅拷贝视图,仅作导航展示,无编辑交互 -->
|
||||
<FmsTree
|
||||
v-model:expanded-keys="expandedCategoryKeys"
|
||||
show-root
|
||||
root-title="全部分类"
|
||||
:tree-data="filteredCategoryTree"
|
||||
:selected-keys="selectedCategoryKey ? [String(selectedCategoryKey)] : []"
|
||||
@select="onCategorySelect"
|
||||
>
|
||||
<template #title="{ title }">
|
||||
<span class="od-tree-node__name">{{ title }}</span>
|
||||
</template>
|
||||
</FmsTree>
|
||||
</div>
|
||||
|
||||
<div v-if="categoryLoading" class="od-tree-panel__loading">
|
||||
<Spin />
|
||||
</div>
|
||||
</aside>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel>
|
||||
<main class="od-list">
|
||||
<FmsModuleListPage
|
||||
v-if="selectedModuleCode"
|
||||
ref="listRef"
|
||||
:key="selectedModuleCode"
|
||||
:data-code="selectedModuleCode"
|
||||
:editable="canUpdateRow"
|
||||
:row-draggable="canUpdateRow"
|
||||
order-field="b_xh"
|
||||
:row-selection="rowSelection"
|
||||
@dirty-change="({ count }) => (dirtyCount = count)"
|
||||
>
|
||||
<template #actions="{ loading }">
|
||||
<Button
|
||||
v-if="canCreateRow"
|
||||
type="primary"
|
||||
:disabled="loading || editableSaving"
|
||||
@click="addNewRow"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
新增行
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canUpdateRow"
|
||||
type="primary"
|
||||
:loading="editableSaving"
|
||||
:disabled="loading || !dirtyCount"
|
||||
@click="saveEditableChanges"
|
||||
>
|
||||
<Save :size="14" />
|
||||
保存{{ dirtyCount ? `(${dirtyCount})` : '' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canDeleteRow"
|
||||
type="danger"
|
||||
:disabled="loading || editableSaving || !selectedRowKeys.length"
|
||||
@click="confirmBatchDelete"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
批量删除{{ selectedRowKeys.length ? `(${selectedRowKeys.length})` : '' }}
|
||||
</Button>
|
||||
</template>
|
||||
</FmsModuleListPage>
|
||||
<div v-else class="od-list-empty">
|
||||
<div class="od-list-empty__hint">
|
||||
<strong>请选择数据模块</strong>
|
||||
<span>从左侧分类树中选择一个数据模块以查看与维护其数据</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.otherdata-page {
|
||||
<style scoped lang="scss">
|
||||
.od-page,
|
||||
.od-splitter {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.od-page {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.od-splitter :deep(> .fms-splitter-panel) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.od-splitter :deep(.fms-splitter-bar::before) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.od-splitter :deep(.fms-splitter-bar:hover::before),
|
||||
.od-splitter :deep(.fms-splitter-bar-active::before),
|
||||
.od-splitter :deep(.fms-splitter-bar:focus-visible::before) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 灰底画布上的白卡,与右侧列表卡用灰缝分隔(与 othercompany 同款布局) */
|
||||
.od-tree-panel {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.od-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.od-tree-search {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.od-tree-search__icon {
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.od-tree-panel__scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.od-tree-panel__scroll :deep(.tree) {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.od-tree-node__name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.od-tree-panel__loading {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(255 255 255 / 60%);
|
||||
}
|
||||
|
||||
/* 列表卡自带白底(.fms-module-card),外层不再铺白底,避免灰缝被染色 */
|
||||
.od-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.od-list-empty {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
place-items: center;
|
||||
background: var(--fms-card);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-shadow);
|
||||
}
|
||||
|
||||
.od-list-empty__hint {
|
||||
display: flex;
|
||||
max-width: 360px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
|
||||
strong {
|
||||
color: var(--fms-text);
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { computed, ref } from 'vue'
|
||||
import { Languages } from '@lucide/vue'
|
||||
import { Button, Form, FormItem, Input, Select } from '@/components/ui'
|
||||
import { Button, Form, FormItem, Input, Select, Tag } from '@/components/ui'
|
||||
import IconPicker from '@/components/icon-picker/IconPicker.vue'
|
||||
import { menuRouteOptions } from '@/router/routes'
|
||||
import { MENU_TYPE_OPTIONS } from './utils'
|
||||
@@ -17,20 +17,35 @@ import { MENU_TYPE_OPTIONS } from './utils'
|
||||
const props = defineProps({
|
||||
/** 已保存菜单不允许修改菜单类型(父级按未保存标记计算) */
|
||||
typeLocked: { type: Boolean, default: false },
|
||||
/** 可绑定的业务模块下拉(父级加载一次后传入,避免每次选菜单重复请求) */
|
||||
moduleOptions: { type: Array, default: () => [] },
|
||||
/** 模块清单(s_module 启用行,父级加载一次后传入,用于展示已绑模块名称) */
|
||||
moduleRows: { type: Array, default: () => [] },
|
||||
/** 页面加载 / 保存中:禁用表单 */
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['openI18n'])
|
||||
const emit = defineEmits(['openI18n', 'openModulePicker'])
|
||||
|
||||
/** 菜单主表行(s_menu),父级 v-model 绑定 maindata */
|
||||
const maindata = defineModel({ type: Object, default: null })
|
||||
|
||||
/**
|
||||
* 已绑定模块编码(s_menu_module 草稿,父级持有并随菜单保存一并提交)。
|
||||
* 交互上通过模块树多选:勾分类节点自动级联其下全部模块,落库只登记 data / virtual。
|
||||
*/
|
||||
const modules = defineModel('modules', { type: Array, default: () => [] })
|
||||
|
||||
const isPage = computed(() => maindata.value?.b_menu_type === 'page')
|
||||
const isExternal = computed(() => maindata.value?.b_menu_type === 'external')
|
||||
|
||||
/** 已绑模块 → 展示项(模块表缺该编码时回退显示编码) */
|
||||
const boundModules = computed(() => {
|
||||
const nameMap = Object.fromEntries(props.moduleRows.map((m) => [String(m.b_id), m.b_name]))
|
||||
return modules.value.map((id) => ({ id: String(id), name: nameMap[String(id)] || String(id) }))
|
||||
})
|
||||
/** 标签最多展示前几个,其余收成 +N,避免长清单撑破表单 */
|
||||
const MAX_VISIBLE_MODULES = 3
|
||||
const visibleModules = computed(() => boundModules.value.slice(0, MAX_VISIBLE_MODULES))
|
||||
|
||||
/**
|
||||
* 路由地址候选:由静态路由表(@/router/routes)派生的页面路由,
|
||||
* Select 的默认筛选即对 label / value 做包含匹配,等价于 like '%关键词%',
|
||||
@@ -52,8 +67,8 @@ const formRules = computed(() => ({
|
||||
b_name: [{ required: true, message: '请填写菜单名称' }],
|
||||
b_menu_type: [{ required: true, message: '请选择菜单类型' }],
|
||||
b_route: isPage.value || isExternal.value ? [{ required: true, message: '请填写路由地址' }] : [],
|
||||
// 绑定模块非必填:页面菜单可不绑定业务模块(仅用于路由 + 数据来源),
|
||||
// 留空时保存为空,后续需要再补选。
|
||||
// 绑定模块非必填:页面菜单可不绑定模块(模块绑定存 s_menu_module 关系表),
|
||||
// 未绑定不报错,后续需要再补选。
|
||||
b_module_id: [],
|
||||
}))
|
||||
|
||||
@@ -75,12 +90,12 @@ function onMenuTypeChange(menuType) {
|
||||
if (!maindata.value) return
|
||||
if (props.typeLocked) return
|
||||
maindata.value.b_menu_type = menuType
|
||||
// 目录和外链不绑定业务模块;页面同时使用路由和模块。
|
||||
// 目录和外链不绑定业务模块(清空绑定草稿,保存时关系行按 diff 删除);页面同时使用路由和模块。
|
||||
if (menuType === 'directory') {
|
||||
maindata.value.b_route = null
|
||||
maindata.value.b_module_id = null
|
||||
modules.value = []
|
||||
} else if (menuType === 'external') {
|
||||
maindata.value.b_module_id = null
|
||||
modules.value = []
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -142,14 +157,19 @@ function onMenuTypeChange(menuType) {
|
||||
<FormItem v-if="isExternal" label="外链地址" name="b_route" required>
|
||||
<Input :model-value="maindata.b_route" @update:model-value="(v) => setField('b_route', v)" />
|
||||
</FormItem>
|
||||
<!-- page: 绑定模块(非必填,可不绑定业务模块) -->
|
||||
<!-- page: 绑定模块(多选;点模块树分类节点自动级联勾选其下全部模块) -->
|
||||
<FormItem v-if="isPage" label="绑定模块" name="b_module_id">
|
||||
<Select
|
||||
:model-value="maindata.b_module_id"
|
||||
:options="moduleOptions"
|
||||
clearable
|
||||
@update:model-value="(v) => setField('b_module_id', v)"
|
||||
/>
|
||||
<div class="mm-module-field">
|
||||
<div class="mm-module-field__value">
|
||||
<Tag v-for="m in visibleModules" :key="m.id">{{ m.name }}</Tag>
|
||||
<span v-if="boundModules.length > visibleModules.length" class="mm-module-field__more">
|
||||
+{{ boundModules.length - visibleModules.length }}
|
||||
</span>
|
||||
</div>
|
||||
<Button type="outline" :disabled="loading" @click="emit('openModulePicker')">
|
||||
选择模块
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem label="图标" name="b_icon">
|
||||
<IconPicker
|
||||
@@ -184,4 +204,30 @@ function onMenuTypeChange(menuType) {
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* 绑定模块:只读展示框 + 选择按钮(多选走模块树弹层) */
|
||||
.mm-module-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mm-module-field__value {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
}
|
||||
|
||||
.mm-module-field__more {
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -4,12 +4,12 @@
|
||||
*
|
||||
* 职责:搜索框与树展示,展开 / 选中 / 拖拽 / 右键菜单的 UI 交互,节点启用停用的操作入口。
|
||||
* 数据契约:树数据、选中项、未保存标记均由父级唯一持有,本组件只读并通过事件上报意图:
|
||||
* select / add / delete / drop / refresh / toggleEnable。
|
||||
* select / add / delete / drop / toggleEnable。
|
||||
* 搜索关键字与展开键是纯 UI 状态,留在本组件;展开键需由父级在新建菜单后追加祖先路径,
|
||||
* 故以 v-model:expanded-keys 暴露。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ExternalLink, FileText, Folder, Plus, RefreshCw, Search, Trash2 } from '@lucide/vue'
|
||||
import { ExternalLink, FileText, Folder, Plus, Search, Trash2 } from '@lucide/vue'
|
||||
import { Button, Checkbox, Dropdown, Input } from '@/components/ui'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
import { collectAllKeys, filterTreeByText } from '@/utils/tree'
|
||||
@@ -27,15 +27,7 @@ const props = defineProps({
|
||||
unsavedIds: { type: Set, default: () => new Set() },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:expandedKeys',
|
||||
'select',
|
||||
'add',
|
||||
'delete',
|
||||
'drop',
|
||||
'refresh',
|
||||
'toggleEnable',
|
||||
])
|
||||
const emit = defineEmits(['update:expandedKeys', 'select', 'add', 'delete', 'drop', 'toggleEnable'])
|
||||
|
||||
/** 搜索关键字(纯 UI 状态) */
|
||||
const keyword = ref('')
|
||||
@@ -94,15 +86,6 @@ function onToolbarAdd({ key }) {
|
||||
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
|
||||
</Input>
|
||||
<div class="mm-tree-header__actions">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-tree-header__icon-btn"
|
||||
title="刷新菜单树"
|
||||
aria-label="刷新菜单树"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCw :size="14" />
|
||||
</Button>
|
||||
<Dropdown :items="addOptions" @click="onToolbarAdd">
|
||||
<Button
|
||||
type="ghost"
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 模块绑定选择弹层:按模块树勾选页面使用的模块。
|
||||
*
|
||||
* 交互:勾选分类节点(module)自动级联勾选其下全部模块 —— 由 Tree 的
|
||||
* checkable 非严格模式提供(点父级整棵子树开启 / 关闭,半选态自动计算)。
|
||||
* 落库只登记 data / virtual 模块编码:module 类型只是模块树上的分类,不参与权限,
|
||||
* 确定时统一过滤掉;勾选草稿(含分类节点)仅存在于弹层内部。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Modal } from '@/components/ui'
|
||||
import FmsTree from '@/components/fms-tree/FmsTree.vue'
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
/** 模块树(module 分类 + data / virtual 数据模块) */
|
||||
treeData: { type: Array, default: () => [] },
|
||||
/** 已绑定模块编码(data / virtual) */
|
||||
checkedKeys: { type: Array, default: () => [] },
|
||||
/** 模块编码 → b_module_type;缺少映射的键按可绑定处理,避免误删历史绑定 */
|
||||
moduleTypes: { type: Object, default: () => ({}) },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:open', 'confirm'])
|
||||
|
||||
/** 弹层内的勾选草稿(含分类节点;关闭不落库,重新打开时按外部值重置) */
|
||||
const draftChecked = ref([])
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(value) => {
|
||||
if (value) draftChecked.value = [...props.checkedKeys]
|
||||
},
|
||||
)
|
||||
|
||||
/** 可绑定 = data / virtual(module 仅分类) */
|
||||
function isBindable(key) {
|
||||
const type = props.moduleTypes[String(key)]
|
||||
return type == null || type === 'data' || type === 'virtual'
|
||||
}
|
||||
|
||||
/** 初始展开:只展开第一层根节点,深层分类按需点开,避免一次铺开整棵树 */
|
||||
const rootKeys = computed(() => (props.treeData || []).map((node) => node.b_id))
|
||||
|
||||
function onOk() {
|
||||
emit(
|
||||
'confirm',
|
||||
draftChecked.value.filter(isBindable).map((key) => String(key)),
|
||||
)
|
||||
emit('update:open', false)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
:open="open"
|
||||
title="绑定模块"
|
||||
:width="560"
|
||||
@update:open="(value) => emit('update:open', value)"
|
||||
@ok="onOk"
|
||||
>
|
||||
<div class="mp-tree">
|
||||
<FmsTree
|
||||
v-model:checked-keys="draftChecked"
|
||||
:tree-data="treeData"
|
||||
:default-expanded-keys="rootKeys"
|
||||
checkable
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mp-tree {
|
||||
max-height: 52vh;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
}
|
||||
</style>
|
||||
@@ -34,6 +34,7 @@ import UnsavedChangesModal from '@/components/UnsavedChangesModal.vue'
|
||||
import I18nNameModal from '@/components/I18nNameModal.vue'
|
||||
import MenuTreePanel from './MenuTreePanel.vue'
|
||||
import MenuBasicPanel from './MenuBasicPanel.vue'
|
||||
import ModulePickerModal from './ModulePickerModal.vue'
|
||||
import {
|
||||
addRowUpdate,
|
||||
applyMenuPosition,
|
||||
@@ -51,6 +52,11 @@ function sqlText(value) {
|
||||
return `N'${String(value ?? '').replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
/** 编码数组的顺序无关签名(比较模块绑定集合是否变化) */
|
||||
function sortedKey(list) {
|
||||
return (list || []).map(String).toSorted().join(',')
|
||||
}
|
||||
|
||||
/* ================= 多语言配置(s_i18n_type 驱动) ================= */
|
||||
// 菜单多语言:默认语言(b_default=1)名称直接存 b_name;其余语言存 s_i18n(b_key=menu.编码)。
|
||||
// 语言清单来自数据库(stores/i18n),不再写死 zh-CN/en-US。
|
||||
@@ -105,6 +111,13 @@ const i18n_org = ref([])
|
||||
/** 多语言名称编辑弹窗 */
|
||||
const i18nModalVisible = ref(false)
|
||||
|
||||
/** 绑定模块草稿(s_menu_module 的模块编码数组,随菜单保存一并提交) */
|
||||
const menuModules = ref([])
|
||||
/** 绑定模块加载基线(保存时行级 diffRows 的原始集合) */
|
||||
const menuModules_org = ref([])
|
||||
/** 模块绑定选择弹层 */
|
||||
const modulePickerOpen = ref(false)
|
||||
|
||||
/** 菜单基础信息面板(保存前校验入口) */
|
||||
const basicPanelRef = ref(null)
|
||||
|
||||
@@ -154,37 +167,6 @@ async function loadMenuTree() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具栏「刷新」:loadMenuTree 会用库里数据整体重建树,
|
||||
* 未保存的新建菜单会凭空消失、当前草稿也会与新树脱节,故有未保存内容时先确认。
|
||||
*/
|
||||
function requestReloadTree() {
|
||||
const hasDraftNode = [...unsavedMenuIds].some((id) => findTreeNode(menuTreeData.value, id))
|
||||
// 选中未保存节点时,maindata 与 maindata_org 一致(未编辑态),hasUnsavedChanges 判不出来,需单独看
|
||||
const editingDraftNode =
|
||||
!!menuSelectedId.value && unsavedMenuIds.has(String(menuSelectedId.value))
|
||||
const dirty = loadedMenuId.value && hasUnsavedChanges()
|
||||
if (!hasDraftNode && !dirty) {
|
||||
void loadMenuTree()
|
||||
return
|
||||
}
|
||||
showConfirm({
|
||||
title: '刷新菜单树',
|
||||
content:
|
||||
'刷新会用数据库数据重建整棵树,当前未保存的修改与未保存的新建菜单都将丢失。确定继续吗?',
|
||||
okText: '刷新',
|
||||
onOk: async () => {
|
||||
// 选中项若为未保存节点,刷新后它必然不在树上:先清空选中,避免右侧残留悬空草稿。
|
||||
// 已保存节点则记录 id,树刷新后连同草稿一起重载,保持左右两侧一致。
|
||||
const reloadId =
|
||||
editingDraftNode || !menuSelectedId.value ? null : String(menuSelectedId.value)
|
||||
if (editingDraftNode) menuSelectedId.value = null
|
||||
await loadMenuTree()
|
||||
if (reloadId) await loadMenuConfig(reloadId)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 工具栏「重载」:重载会放弃当前未保存的改动,先确认 */
|
||||
function requestReloadConfig() {
|
||||
const id = menuSelectedId.value
|
||||
@@ -342,7 +324,6 @@ async function addMenu(parentId, menuTypeValue = 'page') {
|
||||
b_i18n: null,
|
||||
b_menu_type: menuTypeValue,
|
||||
b_route: null,
|
||||
b_module_id: null,
|
||||
b_icon: null,
|
||||
b_xh: 10,
|
||||
b_canuse: 1,
|
||||
@@ -397,6 +378,8 @@ async function deleteMenu(id) {
|
||||
// s_i18n 无菜单归属列,按资源键删该菜单的全部语言行
|
||||
const reqs = [
|
||||
{ table: 's_i18n', key_field: 'b_key', deletes: [{ b_key: buildMenuI18nKey(menuId) }] },
|
||||
// 模块绑定关系随菜单删除(b_menu_id 为复合主键首列,按它批量删)
|
||||
{ table: 's_menu_module', key_field: 'b_menu_id', deletes: [{ b_menu_id: menuId }] },
|
||||
]
|
||||
reqs.push({ table: 's_menu', key_field: 'b_id', deletes: [{ b_id: menuId }] })
|
||||
await saveObjectApi(reqs)
|
||||
@@ -444,6 +427,15 @@ async function loadMenuConfig(menuId) {
|
||||
i18n_values.value = i18nInitValues
|
||||
i18n_values_org.value = { ...i18nInitValues }
|
||||
i18n_org.value = i18n_org_rows
|
||||
// 模块绑定(s_menu_module):行身份 (b_menu_id, b_module_id),只登记 data / virtual 模块
|
||||
const moduleRes = await loadDataApi(
|
||||
's_menu_module',
|
||||
`b_menu_id = ${sqlText(id)}`,
|
||||
'b_xh ASC, b_module_id ASC',
|
||||
)
|
||||
if (String(menuSelectedId.value) !== id) return
|
||||
menuModules_org.value = (moduleRes.data || []).map((row) => String(row.b_module_id))
|
||||
menuModules.value = [...menuModules_org.value]
|
||||
loadedMenuId.value = id
|
||||
} catch (e) {
|
||||
if (String(menuSelectedId.value) === String(menuId)) {
|
||||
@@ -466,6 +458,8 @@ watch(menuSelectedId, (id) => {
|
||||
configLoading.value = false
|
||||
loadedMenuId.value = null
|
||||
maindata.value = null
|
||||
menuModules.value = []
|
||||
menuModules_org.value = []
|
||||
return
|
||||
}
|
||||
// 未落库的新节点:临时 id 在库里查不到行,不能走 loadMenuConfig
|
||||
@@ -484,6 +478,8 @@ watch(menuSelectedId, (id) => {
|
||||
i18n_values.value = { [defaultLocale.value]: node.b_name || '' }
|
||||
i18n_values_org.value = { ...i18n_values.value }
|
||||
i18n_org.value = []
|
||||
menuModules.value = []
|
||||
menuModules_org.value = []
|
||||
loadedMenuId.value = String(id)
|
||||
return
|
||||
}
|
||||
@@ -507,12 +503,14 @@ function hasUnsavedChanges() {
|
||||
)
|
||||
)
|
||||
return true
|
||||
// 模块绑定变化视为未保存(顺序无关)
|
||||
if (sortedKey(menuModules.value) !== sortedKey(menuModules_org.value)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一的「有未保存改动就先确认」闸门:无改动直接执行 action,有改动弹窗等待用户选择。
|
||||
* 所有会覆盖当前草稿的操作(切换节点 / 新建菜单 / 刷新树)都必须经此,
|
||||
* 所有会覆盖当前草稿的操作(切换节点 / 新建菜单)都必须经此,
|
||||
* 否则会出现「某条路径静默丢弃编辑」的不一致。
|
||||
*/
|
||||
function confirmIfUnsaved(action) {
|
||||
@@ -594,9 +592,9 @@ async function beforeSave() {
|
||||
)
|
||||
return false
|
||||
}
|
||||
// 绑定模块非必填:页面菜单可不绑定业务模块,留空即保存为空
|
||||
// 路由 / 模块按菜单类型收敛:目录、外链不绑模块(清空草稿,保存时按 diff 删除关系行)
|
||||
if (!typeRule.route && menuData.b_route) menuData.b_route = null
|
||||
if (!typeRule.module && menuData.b_module_id) menuData.b_module_id = null
|
||||
if (!typeRule.module && menuModules.value.length) menuModules.value = []
|
||||
|
||||
// 字符串业务键:菜单 b_id 即编码,创建后不可修改;保存时统一去除首尾空格
|
||||
const menuCode = String(menuData.b_id || '').trim()
|
||||
@@ -681,6 +679,32 @@ async function buildSaveData() {
|
||||
reqs.push(i18nReq)
|
||||
}
|
||||
|
||||
// 模块绑定(s_menu_module):行身份 (b_menu_id, b_module_id),只提交增删行;
|
||||
// b_xh 记录页面内模块顺序(按当前勾选顺序 10 起步),顺序变化会产生 update,可接受。
|
||||
const toModuleRows = (list) =>
|
||||
list.map((moduleId, index) => ({
|
||||
b_menu_id: menuId,
|
||||
b_module_id: String(moduleId),
|
||||
b_xh: (index + 1) * 10,
|
||||
b_canuse: 1,
|
||||
}))
|
||||
const moduleChanges = diffRows(
|
||||
toModuleRows(menuModules.value),
|
||||
toModuleRows(menuModules_org.value),
|
||||
{ keyFields: ['b_menu_id', 'b_module_id'] },
|
||||
)
|
||||
if (
|
||||
moduleChanges.inserts.length ||
|
||||
moduleChanges.updates.length ||
|
||||
moduleChanges.deletes.length
|
||||
) {
|
||||
const moduleReq = { table: 's_menu_module', key_field: 'b_menu_id,b_module_id' }
|
||||
if (moduleChanges.inserts.length) moduleReq.inserts = moduleChanges.inserts
|
||||
if (moduleChanges.updates.length) moduleReq.updates = moduleChanges.updates
|
||||
if (moduleChanges.deletes.length) moduleReq.deletes = moduleChanges.deletes
|
||||
reqs.push(moduleReq)
|
||||
}
|
||||
|
||||
return reqs.length ? reqs : null
|
||||
}
|
||||
|
||||
@@ -787,22 +811,24 @@ async function onToggleEnable({ node, checked }) {
|
||||
}
|
||||
}
|
||||
|
||||
/* ================= 加载模块列表(供页面类型下拉选择) ================= */
|
||||
const moduleOptions = ref([])
|
||||
/* ================= 加载模块清单(绑定模块选择器) ================= */
|
||||
/** 模块平铺行(s_module 启用行;已绑模块名称展示与类型过滤用) */
|
||||
const moduleRows = ref([])
|
||||
/** 模块树(module 分类 + data / virtual 数据模块;绑定弹层勾选用) */
|
||||
const moduleTree = ref([])
|
||||
/** 模块编码 → 模块类型(弹层确定时过滤掉 module 分类节点) */
|
||||
const moduleTypes = computed(() =>
|
||||
Object.fromEntries(moduleRows.value.map((m) => [String(m.b_id), String(m.b_module_type)])),
|
||||
)
|
||||
let moduleOptionsLoaded = false
|
||||
|
||||
async function loadModuleOptions() {
|
||||
if (moduleOptionsLoaded) return
|
||||
try {
|
||||
const res = await loadDataApi(
|
||||
's_module',
|
||||
"b_canuse = 1 AND b_module_type IN ('data', 'virtual')",
|
||||
'b_xh ASC, b_id ASC',
|
||||
)
|
||||
moduleOptions.value = (res.data || []).map((m) => ({
|
||||
label: `[${m.b_id}] ${m.b_name}`,
|
||||
value: String(m.b_id),
|
||||
}))
|
||||
const res = await loadDataApi('s_module', 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC')
|
||||
const rows = res.data || []
|
||||
moduleRows.value = rows
|
||||
moduleTree.value = sortTree(buildTree(rows.map((row) => ({ ...row }))))
|
||||
moduleOptionsLoaded = true
|
||||
} catch (e) {
|
||||
// Silently fail; modules will be empty
|
||||
@@ -860,7 +886,6 @@ loadModuleOptions()
|
||||
@add="onMenuAdd"
|
||||
@delete="(id) => void deleteMenu(id)"
|
||||
@drop="(payload) => void onMenuDrop(payload)"
|
||||
@refresh="requestReloadTree"
|
||||
@toggle-enable="(payload) => void onToggleEnable(payload)"
|
||||
/>
|
||||
</SplitterPanel>
|
||||
@@ -894,10 +919,12 @@ loadModuleOptions()
|
||||
<MenuBasicPanel
|
||||
ref="basicPanelRef"
|
||||
v-model="maindata"
|
||||
v-model:modules="menuModules"
|
||||
:type-locked="menuTypeLocked"
|
||||
:module-options="moduleOptions"
|
||||
:module-rows="moduleRows"
|
||||
:loading="busy"
|
||||
@open-i18n="i18nModalVisible = true"
|
||||
@open-module-picker="modulePickerOpen = true"
|
||||
/>
|
||||
</div>
|
||||
</SplitterPanel>
|
||||
@@ -941,6 +968,16 @@ loadModuleOptions()
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- 绑定模块选择(模块树多选:勾分类节点自动级联其下全部模块) -->
|
||||
<ModulePickerModal
|
||||
:open="modulePickerOpen"
|
||||
:tree-data="moduleTree"
|
||||
:checked-keys="menuModules"
|
||||
:module-types="moduleTypes"
|
||||
@update:open="(v) => (modulePickerOpen = v)"
|
||||
@confirm="menuModules = $event"
|
||||
/>
|
||||
|
||||
<!-- 多语言名称编辑(通用组件,与模块配置共用) -->
|
||||
<I18nNameModal
|
||||
:open="i18nModalVisible"
|
||||
|
||||
@@ -45,7 +45,6 @@ const MENU_ROW_FIELDS = [
|
||||
'b_i18n',
|
||||
'b_menu_type',
|
||||
'b_route',
|
||||
'b_module_id',
|
||||
'b_icon',
|
||||
'b_xh',
|
||||
'b_canuse',
|
||||
|
||||
@@ -170,7 +170,7 @@ function batchDelete() {
|
||||
}
|
||||
|
||||
/* ================= 字段库交互 ================= */
|
||||
// 已加入的字段(供 FieldLibrary ✓ 标记与点击定位)
|
||||
// 已加入的字段(供 FieldLibrary ✓ 标记与点击移除)
|
||||
function findRowByFieldId(fieldKey) {
|
||||
return queryConfig.value.find((row) => String(row.b_field) === String(fieldKey))
|
||||
}
|
||||
@@ -181,14 +181,13 @@ const selectedFieldId = computed(() => {
|
||||
return row?.b_field != null ? String(row.b_field) : null
|
||||
})
|
||||
|
||||
// 字段库点击:已加入 → 定位表格行;未加入 → 生成默认查询行加入
|
||||
// 字段库点击:未加入 → 生成默认查询行加入;已加入 → 再次点击即移除(加入 / 移除切换)
|
||||
function onFieldSelect(field) {
|
||||
if (!field || props.loading) return
|
||||
const fieldKey = String(field.b_field)
|
||||
const existing = findRowByFieldId(fieldKey)
|
||||
if (existing) {
|
||||
activeKey.value = String(existing._rowKey)
|
||||
nextTick(() => tableRef.value?.scrollToRow?.(String(existing._rowKey)))
|
||||
removeRow(existing)
|
||||
return
|
||||
}
|
||||
const row = {
|
||||
@@ -244,14 +243,16 @@ const columns = shallowRef([
|
||||
:selected-field-id="selectedFieldId"
|
||||
:loading="loading"
|
||||
drag-mode="native"
|
||||
item-title="点击加入查询;已加入的点击定位;从右侧表格删除行即移除"
|
||||
added-title="已加入查询"
|
||||
item-title="点击加入查询;已加入的再次点击移除"
|
||||
added-title="已加入查询,点击移除"
|
||||
@select="onFieldSelect"
|
||||
/>
|
||||
|
||||
<div class="mm-qdesign__main">
|
||||
<div class="mm-qdesign__toolbar">
|
||||
<Button type="primary" :disabled="loading" @click="addRow"><Plus :size="14" /> 新增查询条件</Button>
|
||||
<Button type="primary" :disabled="loading" @click="addRow"
|
||||
><Plus :size="14" /> 新增查询条件</Button
|
||||
>
|
||||
<Button type="danger" :disabled="loading || !selectedRowKeys.length" @click="batchDelete">
|
||||
<Trash2 :size="14" /> 批量删除
|
||||
</Button>
|
||||
|
||||
+5
-6
@@ -688,12 +688,11 @@ function scrollToRowCard(rowId) {
|
||||
|
||||
function selectField(field) {
|
||||
if (!field || props.loading) return
|
||||
// 已加入表单的字段:点击定位选中画布卡片(移除走 Delete / 卡片删除按钮,
|
||||
// 与拖拽已加入字段会被拒绝的语义保持一致,避免误删)
|
||||
// 已加入表单的字段:再次点击即移除(字段库点击 = 加入 / 移除切换,
|
||||
// 与卡片删除按钮 / Delete 键同一移除通道,自动回收栅格)
|
||||
const existing = localEdit.value.find((r) => String(r.b_field) === String(field.b_field))
|
||||
if (existing) {
|
||||
selectRow(existing)
|
||||
nextTick(() => scrollToRowCard(String(existing._rowKey)))
|
||||
removeRow(existing)
|
||||
return
|
||||
}
|
||||
// 未加入的字段:插入到表单并定位到画布卡片
|
||||
@@ -1390,8 +1389,8 @@ onBeforeUnmount(() => {
|
||||
onStart: onLibDragStart,
|
||||
onEnd: onLibDragEnd,
|
||||
}"
|
||||
item-title="点击加入表单;已加入的点击定位到画布卡片"
|
||||
added-title="已加入表单"
|
||||
item-title="点击加入表单;已加入的再次点击移除"
|
||||
added-title="已加入表单,点击移除"
|
||||
@select="selectField"
|
||||
/>
|
||||
|
||||
|
||||
+43
-8
@@ -345,15 +345,44 @@ const dataColumns = computed(() => {
|
||||
]
|
||||
if (isSubtable.value) {
|
||||
cols.push(
|
||||
{ dataIndex: 'b_visible', title: '显示', width: 70, align: 'center', editor: { type: 'checkbox' } },
|
||||
{ dataIndex: 'b_required', title: '必填', width: 70, align: 'center', editor: { type: 'checkbox' } },
|
||||
{ dataIndex: 'b_readonly', title: '只读', width: 70, align: 'center', editor: { type: 'checkbox' } },
|
||||
{ dataIndex: 'b_disabled', title: '禁用', width: 70, align: 'center', editor: { type: 'checkbox' } },
|
||||
{
|
||||
dataIndex: 'b_visible',
|
||||
title: '显示',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_required',
|
||||
title: '必填',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_readonly',
|
||||
title: '只读',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{
|
||||
dataIndex: 'b_disabled',
|
||||
title: '禁用',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
editor: { type: 'checkbox' },
|
||||
},
|
||||
{ dataIndex: 'b_width', title: '列宽(px)', width: 110, editor: {} },
|
||||
)
|
||||
} else {
|
||||
cols.push(
|
||||
{ dataIndex: 'b_width', title: '列宽', width: 90, editor: { type: 'input', inputType: 'number' } },
|
||||
{
|
||||
dataIndex: 'b_width',
|
||||
title: '列宽',
|
||||
width: 90,
|
||||
editor: { type: 'input', inputType: 'number' },
|
||||
},
|
||||
{ dataIndex: 'b_i18n', title: '标题多语言', width: 220 },
|
||||
)
|
||||
}
|
||||
@@ -380,8 +409,12 @@ const treeFeatures = computed(() => ({
|
||||
:selected-field-id="selectedRow?.b_field"
|
||||
:loading="loading"
|
||||
drag-mode="native"
|
||||
:item-title="isSubtable ? '点击加入表格;已加入的点击定位;拖拽到下方分组树可加入对应分组' : '点击加入列表;已加入的点击定位;拖拽到下方分组树可加入对应分组'"
|
||||
:added-title="isSubtable ? '已加入表格' : '已加入列表'"
|
||||
:item-title="
|
||||
isSubtable
|
||||
? '点击加入表格;已加入的再次点击移除;拖拽到下方分组树可加入对应分组'
|
||||
: '点击加入列表;已加入的再次点击移除;拖拽到下方分组树可加入对应分组'
|
||||
"
|
||||
:added-title="isSubtable ? '已加入表格,点击移除' : '已加入列表,点击移除'"
|
||||
@select="selectField"
|
||||
/>
|
||||
|
||||
@@ -426,7 +459,9 @@ const treeFeatures = computed(() => ({
|
||||
<Trash2 :size="14" /> 删除
|
||||
</Button>
|
||||
<Popover trigger="hover" placement="bottom-end" :arrow="false" :offset="8" unwrapped>
|
||||
<template #trigger><span class="mm-edit__help-icon" aria-hidden="true">?</span></template>
|
||||
<template #trigger
|
||||
><span class="mm-edit__help-icon" aria-hidden="true">?</span></template
|
||||
>
|
||||
<template #content>
|
||||
<div class="mm-edit__help-title">操作说明</div>
|
||||
<ul class="mm-edit__help-list">
|
||||
|
||||
+5
-5
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 字段库:展示模块全部字段(含未加入编辑器的字段),按名称 a-z 排序;
|
||||
* 搜索时按名称/编码过滤,命中才显示。点击加入/定位,拖出加入分组。
|
||||
* 字段库:展示模块全部字段(含未加入编辑器的字段),按字段定义顺序(b_xh)排列;
|
||||
* 搜索时按名称/编码过滤,命中才显示。点击加入 / 再次点击移除,拖出加入分组。
|
||||
*
|
||||
* 表格 / 表单两个编辑器共用(文案与选中态经 props 参数化):
|
||||
* - dragMode="native" —— 原生 HTML5 拖拽(表格面板:落点是下方分组结构树)
|
||||
@@ -30,15 +30,15 @@ const props = defineProps({
|
||||
*/
|
||||
sortable: { type: Object, default: null },
|
||||
placeholder: { type: String, default: '搜索字段名 / 编码' },
|
||||
itemTitle: { type: String, default: '点击加入;已加入的点击定位' },
|
||||
addedTitle: { type: String, default: '已加入' },
|
||||
itemTitle: { type: String, default: '点击加入;已加入的再次点击移除' },
|
||||
addedTitle: { type: String, default: '已加入,点击移除' },
|
||||
emptyText: { type: String, default: '无匹配字段' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const libSearch = ref('')
|
||||
// 字段库:展示模块的全部字段(含未加入编辑器的字段),按名称 a-z 排序;
|
||||
// 字段库:展示模块的全部字段(含未加入编辑器的字段),按字段定义顺序(b_xh)排列;
|
||||
// 搜索时按名称/编码过滤,命中才显示
|
||||
const libraryFields = computed(() => filterLibraryFields(props.fields, libSearch.value))
|
||||
|
||||
|
||||
+10
-7
@@ -326,7 +326,7 @@ export function toggleFlag(row, key) {
|
||||
row[key] = Number(row[key]) === 1 ? 0 : 1
|
||||
}
|
||||
|
||||
/** 按关键字过滤字段(名称/编码),按名称排序 */
|
||||
/** 按关键字过滤字段(名称/编码),按字段定义顺序(b_xh,同序号回退 b_field)排列 */
|
||||
export function filterLibraryFields(fields, keyword) {
|
||||
const kw = String(keyword || '')
|
||||
.trim()
|
||||
@@ -342,9 +342,12 @@ export function filterLibraryFields(fields, keyword) {
|
||||
.toLowerCase()
|
||||
.includes(kw),
|
||||
)
|
||||
.toSorted((a, b) =>
|
||||
String(a.b_name || a.b_field || '').localeCompare(String(b.b_name || b.b_field || ''), 'zh'),
|
||||
)
|
||||
.toSorted((a, b) => {
|
||||
// 与字段定义(s_field)的排序一致:b_xh 升序,序号相同再按字段编码
|
||||
const xhDiff = (Number(a.b_xh) || 0) - (Number(b.b_xh) || 0)
|
||||
if (xhDiff !== 0) return xhDiff
|
||||
return String(a.b_field || '').localeCompare(String(b.b_field || ''))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -785,9 +788,9 @@ export function useStructureEditing({
|
||||
if (!field || loading()) return
|
||||
const existing = findRowByFieldId(String(field.b_field))
|
||||
if (existing) {
|
||||
selectRow(existing)
|
||||
nextTick(() => scrollToRowCell(String(existing.b_field)))
|
||||
return existing
|
||||
// 已加入的字段:再次点击即移除(字段库点击 = 加入 / 移除切换)
|
||||
removeRow(existing)
|
||||
return null
|
||||
}
|
||||
const band = selectedBand.value
|
||||
const gid = band ? String(band.gid) : null
|
||||
|
||||
Reference in new issue
Block a user