feat(app): 应用配置公司化——配置页支持范围选择(租户默认/各公司)+ 批量复制到公司,权限随安装范围

This commit is contained in:
TermiNexus committed 2026-08-13 08:48:05 +08:00
1 parent e1f43efc74
commit 7177f99cb8
2 files changed
+195 -17

No files matched your search

+19 -5
View File
@@ -94,9 +94,23 @@ export const appApi = {
/** 租户管理员:待审批申请列表 */
pending: (): Promise<{ list: PendingItem[]; total: number }> => get<{ list: PendingItem[]; total: number }>('/API/App/Pending'),
/** 应用配置:读取(GET)/保存(POST) */
config: (AppUUID: string): Promise<{ Configs: AppConfigItem[]; Total: number }> =>
get<{ Configs: AppConfigItem[]; Total: number }>('/API/App/Config', { AppUUID }),
saveConfig: (AppUUID: string, Config: Record<string, string>): Promise<{ Updated: string[] }> =>
post<{ Updated: string[] }>('/API/App/Config', { AppUUID, Config }),
/** 应用配置:读取(GET,CompanyUUID 可选——空=租户默认配置)/保存(POST,可指定公司) */
config: (AppUUID: string, CompanyUUID?: string): Promise<{ Configs: AppConfigItem[]; Total: number; Scope: string; CompanyUUID: string }> =>
get<{ Configs: AppConfigItem[]; Total: number; Scope: string; CompanyUUID: string }>(
'/API/App/Config',
CompanyUUID ? { AppUUID, CompanyUUID } : { AppUUID },
),
saveConfig: (AppUUID: string, Config: Record<string, string>, CompanyUUID?: string): Promise<{ Updated: string[] }> =>
post<{ Updated: string[] }>(
'/API/App/Config',
CompanyUUID ? { AppUUID, Config, CompanyUUID } : { AppUUID, Config },
),
/** 配置批量复制:把源配置(空=租户默认)复制到目标公司(覆盖同名键) */
copyConfig: (AppUUID: string, TargetCompanyUUIDs: string[], SourceCompanyUUID?: string): Promise<{ Copied: number }> =>
post<{ Copied: number }>(
'/API/App/Config/Copy',
SourceCompanyUUID
? { AppUUID, TargetCompanyUUIDs, SourceCompanyUUID }
: { AppUUID, TargetCompanyUUIDs },
),
}
+176 -12
View File
@@ -2,15 +2,17 @@
/**
* AppConfigPage —— 应用配置页(独立 tab)
*
* 读取应用 Manifest 声明的配置模板 + 当前租户已保存值,
* 有安装权限者(管理员)可编辑保存。
* 入口:应用市场/我的应用 → "配置"按钮(router.push 带 AppUUID/AppName query)
* 接口:GET/POST /API/App/Config
* 读取应用 Manifest 声明的配置模板 + 当前范围已保存值(租户默认/公司级)。
* 权限:
* - 租户管理员:可查看/编辑 租户默认 + 租户内任意公司配置,可批量复制配置到公司
* - 公司管理员:仅可查看/编辑本公司配置
* 读取回退链:公司级配置 → 租户默认配置 → 模板默认值。
* 接口:GET/POST /API/App/Config、POST /API/App/Config/Copy
*/
import { ref, computed, onMounted } from 'vue'
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { appApi, type AppConfigItem } from '@/api/app'
import { appApi, type AppConfigItem, type CompanyScopeItem } from '@/api/app'
import { setPageTitle } from '@/utils/serverScope'
const route = useRoute()
@@ -21,17 +23,52 @@ setPageTitle('应用配置')
const loading = ref(false)
const saving = ref(false)
const copying = ref(false)
const configs = ref<AppConfigItem[]>([])
/** 可管理公司列表(权限范围决定) */
const companies = ref<CompanyScopeItem[]>([])
/** 配置管理范围:all/tenant(可管全部+租户默认)/ company(仅本公司) */
const scope = ref('')
/** 当前编辑范围:'' = 租户默认(全租户);否则为公司 UUID */
const currentCompany = ref('')
/** 当前范围的展示名 */
const scopeLabel = computed(() => {
if (!currentCompany.value) return '租户默认(全租户共享)'
const c = companies.value.find((x) => x.CompanyUUID === currentCompany.value)
return c ? `公司:${c.CompanyName}` : '指定公司'
})
/** 是否为跨公司管理员(可看全部公司 + 复制) */
const isMultiCompany = computed(() => scope.value === 'all' || scope.value === 'tenant')
/** 编辑副本:{Key: 用户输入值}(敏感键未改保持占位符) */
const edited = ref<Record<string, string>>({})
const MASK = '******'
/** 初始化范围:拉取可管理公司,确定默认编辑范围 */
async function initScope(): Promise<void> {
try {
const r = await appApi.manageCompanies()
companies.value = r.Companies || []
scope.value = r.Scope || ''
if (scope.value === 'company') {
// 公司管理员:默认本公司(第一个可管理公司)
currentCompany.value = companies.value[0]?.CompanyUUID || ''
} else {
// 租户/跨租户管理员:默认租户默认配置
currentCompany.value = ''
}
} catch { /* 拦截器已提示 */ }
}
async function loadConfig(): Promise<void> {
if (!AppUUID.value) return
loading.value = true
try {
const r = await appApi.config(AppUUID.value)
const r = await appApi.config(AppUUID.value, currentCompany.value || undefined)
configs.value = r.Configs || []
edited.value = {}
for (const c of configs.value) {
@@ -62,7 +99,7 @@ async function onSave(): Promise<void> {
}
try {
await ElMessageBox.confirm(
'保存后将立即生效。\n请谨慎操作,错误的配置可能导致该应用不可用。',
`保存后将立即生效(范围:${scopeLabel.value})。\n请谨慎操作,错误的配置可能导致该应用不可用。`,
'确认保存配置',
{ type: 'warning', confirmButtonText: '保存', cancelButtonText: '取消' },
)
@@ -78,7 +115,7 @@ async function onSave(): Promise<void> {
if (c.Secret && cur === MASK) continue
payload[c.Key] = cur
}
const r = await appApi.saveConfig(AppUUID.value, payload)
const r = await appApi.saveConfig(AppUUID.value, payload, currentCompany.value || undefined)
ElMessage.success(`配置已保存(${(r.Updated || []).length} 项)`)
await loadConfig()
} catch { /* 拦截器已提示 */ } finally {
@@ -86,7 +123,41 @@ async function onSave(): Promise<void> {
}
}
onMounted(loadConfig)
// ==================== 复制配置到公司 ====================
const copyDialogVisible = ref(false)
const copyTargets = ref<string[]>([])
function openCopyDialog(): void {
copyTargets.value = []
copyDialogVisible.value = true
}
/** 确认复制:把当前范围配置复制到目标公司(覆盖同名键) */
async function onCopy(): Promise<void> {
if (!copyTargets.value.length) {
ElMessage.info('请选择目标公司')
return
}
copying.value = true
try {
const r = await appApi.copyConfig(
AppUUID.value,
copyTargets.value,
currentCompany.value || undefined,
)
ElMessage.success(`已复制 ${r.Copied || 0} 项配置到 ${copyTargets.value.length} 家公司`)
copyDialogVisible.value = false
} catch { /* 拦截器已提示 */ } finally {
copying.value = false
}
}
watch(currentCompany, loadConfig)
onMounted(async () => {
await initScope()
await loadConfig()
})
</script>
<template>
@@ -95,10 +166,38 @@ onMounted(loadConfig)
<template #header>
<div class="cfg-header">
<span class="cfg-title">配置「{{ AppName || '应用' }}」</span>
<span class="cfg-sub">配置按租户隔离,有安装权限者(管理员)可编辑</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"
@@ -114,7 +213,7 @@ onMounted(loadConfig)
:placeholder="c.Placeholder || c.Default"
clearable
/>
<div v-if="c.Updated" class="cfg-meta">已保存</div>
<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>
@@ -122,12 +221,44 @@ onMounted(loadConfig)
<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-dialog
v-model="copyDialogVisible"
:title="`复制配置「${AppName || ''}」到公司`"
width="480px"
:close-on-click-modal="false"
>
<div class="copy-tip">
将当前范围({{ scopeLabel }})的配置复制到目标公司,<b>覆盖目标公司同名键</b>。
</div>
<el-checkbox-group v-model="copyTargets" class="copy-list">
<el-checkbox
v-for="c in companies"
:key="c.CompanyUUID"
:value="c.CompanyUUID"
:disabled="c.CompanyUUID === currentCompany"
class="copy-item"
>
{{ c.CompanyName }}{{ c.CompanyShort ? `(${c.CompanyShort})` : '' }}
</el-checkbox>
</el-checkbox-group>
<template #footer>
<el-button @click="copyDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="copying" :disabled="!copyTargets.length" @click="onCopy">
复制
</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -152,6 +283,23 @@ onMounted(loadConfig)
color: var(--el-text-color-secondary);
}
}
.cfg-scope {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
.cfg-scope-label {
font-size: 13px;
color: var(--el-text-color-regular);
flex-shrink: 0;
}
.cfg-scope-select {
width: 300px;
}
.cfg-scope-tag {
flex-shrink: 0;
}
}
.cfg-body {
min-height: 120px;
}
@@ -166,5 +314,21 @@ onMounted(loadConfig)
.cfg-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.copy-tip {
font-size: 13px;
color: var(--el-text-color-regular);
margin-bottom: 12px;
}
.copy-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 320px;
overflow-y: auto;
}
.copy-item {
margin: 0;
}
</style>