fix(app-config): 虚拟职务绑定按公司隔离,租户默认层不展示绑定

- 新增 bindingEnabled(要求明确选中公司),租户默认层不加载虚拟职务绑定并清空已展示数据、复位脏标记

- 切换范围时仅在选中公司下重新加载绑定,避免误展示/误改他司数据

- getVirtualMapping/setVirtualMapping 注释澄清:CompanyUUID 缺省回落可管理公司
This commit is contained in:
TermiNexus committed 2026-09-15 14:24:46 +08:00
1 parent 9d1d6dca93
commit 1c0885126d
2 files changed
+261 -130

No files matched your search

+2 -2
View File
@@ -175,14 +175,14 @@ export const appApi = {
setStrictVisible: (AppUUID: string, StrictPositionVisible: number): Promise<{ msg: string }> =>
post<{ msg: string }>('/API/App/SetStrictVisible', { AppUUID, StrictPositionVisible }),
/** 管理员:应用虚拟职务↔真实职务映射(配置页读取;CompanyUUID 指定公司范围,空=当前公司) */
/** 管理员:应用虚拟职务↔真实职务映射(配置页读取;CompanyUUID 指定公司范围,缺省回落可管理公司) */
getVirtualMapping: (AppUUID: string, CompanyUUID?: string): Promise<{ VirtualPositions: VirtualMappingItem[]; AllPositions: PositionOption[] }> =>
get<{ VirtualPositions: VirtualMappingItem[]; AllPositions: PositionOption[] }>(
'/API/App/VirtualMapping',
CompanyUUID ? { AppUUID, CompanyUUID } : { AppUUID },
),
/** 管理员:重写虚拟职务的真实职务绑定(配置页保存;CompanyUUID 指定公司范围,空=当前公司) */
/** 管理员:重写虚拟职务的真实职务绑定(配置页保存;CompanyUUID 指定公司范围,缺省回落可管理公司) */
setVirtualMapping: (
AppUUID: string,
Mappings: { AppVirtualPositionUUID: string; PositionUUIDs: string[] }[],
+259 -128
View File
@@ -44,6 +44,12 @@ const scopeLabel = computed(() => {
/** 是否为跨公司管理员(可看全部公司 + 复制) */
const isMultiCompany = computed(() => scope.value === 'all' || scope.value === 'tenant')
/**
* 绑定面板是否可编辑:虚拟职务绑定按公司隔离、无"租户默认"层,
* 必须落在明确选中的公司上(公司管理员恒为本公司)。
*/
const bindingEnabled = computed(() => !!currentCompany.value)
/** 编辑副本:{Key: 用户输入值}(敏感键未改保持占位符) */
const edited = ref<Record<string, string>>({})
@@ -153,10 +159,16 @@ async function onCopy(): Promise<void> {
}
}
// 切换范围(租户默认/公司)→ 配置与虚拟职务绑定均按新范围重新加载
// 切换范围(租户默认/公司)→ 配置随范围加载;虚拟职务绑定仅在有明确公司时加载
watch(currentCompany, () => {
loadConfig()
loadVirtualMapping()
if (currentCompany.value) {
loadVirtualMapping()
} else {
// 租户默认层无虚拟职务绑定:清空已展示数据并复位脏标记
vpMappings.value = []
vpDirty.value = false
}
})
// ==================== 虚拟职务 ↔ 真实职务映射 ====================
@@ -179,7 +191,8 @@ const positionPickerConfig: EntityPickerConfig = {
}
async function loadVirtualMapping(): Promise<void> {
if (!AppUUID.value) return
// 虚拟职务绑定按公司隔离:未选中具体公司(租户默认)不加载,避免误展示/误改他司数据
if (!AppUUID.value || !currentCompany.value) return
vpLoading.value = true
try {
const r = await appApi.getVirtualMapping(AppUUID.value, currentCompany.value || undefined)
@@ -373,149 +386,194 @@ async function onRemoveTemplates(): Promise<void> {
onMounted(async () => {
await initScope()
await loadConfig()
await loadVirtualMapping()
// 虚拟职务绑定按公司隔离:仅在已默认选中本公司(公司管理员)时加载
if (currentCompany.value) {
await loadVirtualMapping()
}
await loadTemplateTree()
})
</script>
<template>
<div class="app-config-page">
<el-card shadow="never" class="cfg-card">
<el-card shadow="never" class="cfg-card main-card">
<template #header>
<div class="cfg-header">
<div class="cfg-header cfg-header-main">
<span class="cfg-title">配置「{{ AppName || '应用' }}」</span>
<span class="cfg-sub">配置按公司隔离:公司级 → 租户默认 → 模板默认</span>
</div>
</template>
<!-- 范围选择(租户管理员可选默认/各公司;公司管理员仅本公司) -->
<div class="cfg-scope">
<span class="cfg-scope-label">配置范围:</span>
<el-select
v-model="currentCompany"
:disabled="!isMultiCompany && companies.length <= 1"
class="cfg-scope-select"
>
<el-option
v-if="isMultiCompany"
label="(默认)租户默认 · 全租户共享"
value=""
/>
<el-option
v-for="c in companies"
:key="c.CompanyUUID"
:label="c.CompanyName"
:value="c.CompanyUUID"
/>
</el-select>
<el-tag v-if="!isMultiCompany" size="small" type="info" effect="plain" class="cfg-scope-tag">
仅限本公司配置
</el-tag>
<el-tag v-else size="small" type="success" effect="plain" class="cfg-scope-tag">
{{ scope === 'all' ? '跨租户管理员' : '租户管理员' }}
</el-tag>
</div>
<div v-loading="loading" class="cfg-body">
<el-empty
v-if="!loading && !configs.length"
description="该应用暂未声明配置项"
:image-size="80"
/>
<el-form v-else label-position="top" class="cfg-form">
<el-form-item v-for="c in configs" :key="c.Key" :label="c.Label">
<el-input
v-model="edited[c.Key]"
:type="c.Secret ? 'password' : 'text'"
:show-password="!!c.Secret"
:placeholder="c.Placeholder || c.Default"
clearable
/>
<div v-if="c.Updated" class="cfg-meta">已保存({{ currentCompany ? '公司级' : '租户默认' }})</div>
<div v-else-if="c.Default" class="cfg-meta">默认:{{ c.Default }}</div>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="cfg-footer">
<el-button v-if="isMultiCompany" :loading="copying" :disabled="!configs.length" @click="openCopyDialog">
复制到公司…
</el-button>
<el-button :loading="saving" :disabled="!configs.length" type="primary" @click="onSave">
保存配置
</el-button>
</div>
</template>
</el-card>
<!-- 虚拟职务 ↔ 真实职务映射:拥有被绑定真实职务的用户看到对应虚拟职务声明的应用菜单 -->
<el-card v-if="AppUUID" shadow="never" class="cfg-card vp-card">
<template #header>
<div class="cfg-header">
<span class="cfg-title">虚拟职务绑定</span>
<span class="cfg-sub">把真实职务对应到应用虚拟职务(由应用清单声明可见菜单)· 绑定范围:{{ scopeLabel }}</span>
<span class="cfg-sub">配置与虚拟职务绑定均按公司隔离 · 公司级 → 租户默认 → 模板默认</span>
<div class="spacer" />
<el-button
size="small"
type="warning"
:loading="vpAutoBinding"
@click="autoBindVirtualPositions"
>
一键绑定
</el-button>
<el-button
size="small"
type="primary"
:loading="vpSaving"
:disabled="!vpDirty"
@click="saveVirtualMapping"
>
保存绑定
</el-button>
<!-- 范围选择(租户管理员可选默认/各公司;公司管理员仅本公司) -->
<div class="cfg-scope">
<span class="cfg-scope-label">配置范围:</span>
<el-select
v-model="currentCompany"
:disabled="!isMultiCompany && companies.length <= 1"
class="cfg-scope-select"
>
<el-option
v-if="isMultiCompany"
label="(默认)租户默认 · 全租户共享"
value=""
/>
<el-option
v-for="c in companies"
:key="c.CompanyUUID"
:label="c.CompanyName"
:value="c.CompanyUUID"
/>
</el-select>
<el-tag v-if="!isMultiCompany" size="small" type="info" effect="plain" class="cfg-scope-tag">
仅限本公司配置
</el-tag>
<el-tag v-else-if="!currentCompany" size="small" type="warning" effect="plain" class="cfg-scope-tag">
租户默认
</el-tag>
<el-tag v-else size="small" type="success" effect="plain" class="cfg-scope-tag">
{{ scope === 'all' ? '跨租户管理员' : '租户管理员' }}
</el-tag>
</div>
</div>
</template>
<div v-loading="vpLoading" class="vp-body">
<div v-for="vp in vpMappings" :key="vp.AppVirtualPositionUUID" class="vp-row">
<div class="vp-info">
<div class="vp-name-row">
<span class="vp-name">{{ vp.Name }}</span>
<span class="vp-code">{{ vp.Code }}</span>
</div>
<div class="vp-menus">
<el-tag
v-for="m in vp.MenuNames"
:key="m"
size="small"
type="info"
effect="plain"
>{{ m }}</el-tag>
<span v-if="!vp.MenuNames.length" class="vp-menus-empty">未声明可见菜单</span>
<div class="main-body">
<!-- 当前范围语义横幅:公司隔离在 UI 上的直接表达 -->
<div class="scope-banner" :class="currentCompany ? 'scope-banner-company' : 'scope-banner-tenant'">
<el-icon><OfficeBuilding /></el-icon>
<div class="scope-banner-text">
<template v-if="!currentCompany">
当前为 <b>租户默认</b> 范围:配置作为<b>全租户各公司的兜底</b>(公司未覆盖的键自动回退到此);
虚拟职务绑定<b>仅按公司生效</b>,请先在右侧选择目标公司再编辑。
</template>
<template v-else>
当前为 <b>{{ scopeLabel }}</b> 范围:所有修改<b>仅影响该公司</b>,与其他公司配置相互隔离;
未覆盖的键自动回退租户默认。
</template>
</div>
</div>
<!-- ==================== 区块 A:配置项 ==================== -->
<div class="section">
<div class="section-head">
<span class="section-title">配置项</span>
<span class="section-sub">
{{ currentCompany ? '公司级保存 · 未保存键回退:租户默认 → 模板默认' : '全租户共享的默认配置 · 各公司可独立覆盖' }}
</span>
</div>
<div v-loading="loading" class="cfg-body">
<el-empty
v-if="!loading && !configs.length"
description="该应用暂未声明配置项"
:image-size="80"
/>
<el-form v-else label-position="top" class="cfg-form">
<el-form-item v-for="c in configs" :key="c.Key" :label="c.Label">
<el-input
v-model="edited[c.Key]"
:type="c.Secret ? 'password' : 'text'"
:show-password="!!c.Secret"
:placeholder="c.Placeholder || c.Default"
clearable
/>
<div v-if="c.Updated" class="cfg-meta">已保存({{ currentCompany ? '公司级' : '租户默认' }})</div>
<div v-else-if="c.Default" class="cfg-meta">默认:{{ c.Default }}</div>
</el-form-item>
</el-form>
</div>
<div class="cfg-footer">
<el-button v-if="isMultiCompany" :loading="copying" :disabled="!configs.length" @click="openCopyDialog">
复制到公司…
</el-button>
<el-button :loading="saving" :disabled="!configs.length" type="primary" @click="onSave">
保存配置
</el-button>
</div>
</div>
<el-divider class="main-divider" />
<!-- ==================== 区块 B:虚拟职务绑定 ==================== -->
<!-- 拥有被绑定真实职务的用户看到对应虚拟职务声明的应用菜单(按公司隔离) -->
<div class="section">
<div class="section-head">
<span class="section-title">虚拟职务绑定</span>
<span class="section-sub">把真实职务对应到应用虚拟职务(由应用清单声明可见菜单)· 绑定范围:{{ scopeLabel }}</span>
<div class="spacer" />
<el-button
v-if="bindingEnabled"
size="small"
type="warning"
:loading="vpAutoBinding"
@click="autoBindVirtualPositions"
>
一键绑定
</el-button>
<el-button
v-if="bindingEnabled"
size="small"
type="primary"
:loading="vpSaving"
:disabled="!vpDirty"
@click="saveVirtualMapping"
>
保存绑定
</el-button>
</div>
<div v-if="bindingEnabled">
<div v-loading="vpLoading" class="vp-body">
<div v-for="vp in vpMappings" :key="vp.AppVirtualPositionUUID" class="vp-row">
<div class="vp-info">
<div class="vp-name-row">
<span class="vp-name">{{ vp.Name }}</span>
<span class="vp-code">{{ vp.Code }}</span>
</div>
<div class="vp-menus">
<el-tag
v-for="m in vp.MenuNames"
:key="m"
size="small"
type="info"
effect="plain"
>{{ m }}</el-tag>
<span v-if="!vp.MenuNames.length" class="vp-menus-empty">未声明可见菜单</span>
</div>
</div>
<MultiEntityPicker
v-model="vp.PositionUUIDs"
:config="positionPickerConfig"
:label-text="vp.PositionNames ? vp.PositionNames.join('、') : ''"
placeholder="绑定真实职务(可多选)"
@update:modelValue="onVpChange"
/>
</div>
<el-empty
v-if="!vpLoading && !vpMappings.length"
description="该应用未声明虚拟职务"
:image-size="60"
/>
</div>
</div>
<MultiEntityPicker
v-model="vp.PositionUUIDs"
:config="positionPickerConfig"
:label-text="vp.PositionNames ? vp.PositionNames.join('、') : ''"
placeholder="绑定真实职务(可多选)"
@update:modelValue="onVpChange"
<el-alert
v-else
class="vp-lock-tip"
type="info"
:closable="false"
show-icon
title="虚拟职务绑定按公司隔离,不存在「租户默认」层"
description="请在上方「配置范围」中选择目标公司后,再进行一键绑定与保存绑定。"
/>
</div>
<el-empty
v-if="!vpLoading && !vpMappings.length"
description="该应用未声明虚拟职务"
:image-size="60"
/>
</div>
</el-card>
<!-- 通用部门与职务模板同步:应用声明的通用部门/职务按名称补齐到当前租户(已有复用) -->
<!-- 通用部门与职务模板同步:租户级资源(部门/职务无公司归属),不受上方配置范围影响 -->
<el-card v-if="AppUUID" shadow="never" class="cfg-card pt-card">
<template #header>
<div class="cfg-header">
<el-icon><OfficeBuilding /></el-icon>
<span class="cfg-title">通用部门与职务模板</span>
<span class="cfg-sub">应用声明的通用部门与职务(按名称匹配,同名已有则直接复用)</span>
<el-tag size="small" type="info" effect="plain" class="pt-scope-tag">租户级 · 不随上方配置范围变化</el-tag>
<div class="spacer" />
<el-button size="small" type="danger" :loading="ptRemoveLoading" plain @click="openRemoveDialog">
移除通用部门与职务
@@ -526,7 +584,8 @@ onMounted(async () => {
</div>
</template>
<div class="pt-tip">
应用声明的通用部门与职务(按名称匹配,同名已有则直接复用)。点击"同步"补齐缺失项;点击"移除"仅删除所有员工均未持有的职务及已清空的通用部门。
部门与职务是<b>租户级字典</b>(各公司共享同一套),同步/移除作用于<b>整个租户</b>,
与上方「配置范围」选择的公司无关。点击"同步"补齐缺失项;点击"移除"仅删除所有员工均未持有的职务及已清空的通用部门。
</div>
<el-tree
:data="ptTreeData"
@@ -664,9 +723,6 @@ onMounted(async () => {
.spacer {
flex: 1;
}
.vp-card {
margin-top: 16px;
}
.pt-card {
margin-top: 16px;
}
@@ -754,29 +810,100 @@ onMounted(async () => {
.cfg-title {
font-weight: 600;
font-size: 15px;
flex-shrink: 0;
}
.cfg-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
min-width: 0;
}
}
.cfg-header-main {
flex-wrap: wrap;
row-gap: 8px;
}
.cfg-scope {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
flex-shrink: 0;
margin-left: auto;
.cfg-scope-label {
font-size: 13px;
color: var(--el-text-color-regular);
flex-shrink: 0;
}
.cfg-scope-select {
width: 300px;
width: 280px;
}
.cfg-scope-tag {
flex-shrink: 0;
}
}
.main-body {
display: flex;
flex-direction: column;
}
.scope-banner {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 18px;
font-size: 13px;
line-height: 1.6;
.el-icon {
margin-top: 2px;
flex-shrink: 0;
font-size: 16px;
}
b {
font-weight: 600;
}
}
.scope-banner-tenant {
color: var(--el-text-color-primary);
background: var(--el-color-warning-light-9);
border: 1px solid var(--el-color-warning-light-5);
.el-icon {
color: var(--el-color-warning);
}
}
.scope-banner-company {
color: var(--el-text-color-primary);
background: var(--el-color-info-light-9);
border: 1px solid var(--el-color-info-light-5);
.el-icon {
color: var(--el-color-info);
}
}
.main-divider {
margin: 18px 0;
}
.section-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 12px;
.section-title {
font-weight: 600;
font-size: 14px;
flex-shrink: 0;
}
.section-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
min-width: 0;
}
}
.vp-lock-tip {
margin-top: 4px;
}
.pt-scope-tag {
flex-shrink: 0;
}
.cfg-body {
min-height: 120px;
}
@@ -791,7 +918,11 @@ onMounted(async () => {
.cfg-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid var(--el-border-color-lighter);
}
.copy-tip {
font-size: 13px;