feat(aihr): add tenant organization governance

This commit is contained in:
2026-07-23 15:58:38 +08:00
parent 6a90fd977c
commit 06322c994b
20 changed files with 1619 additions and 3 deletions
@@ -0,0 +1,101 @@
import request from '@/utils/request';
type ApiResult<T> = {
code: number;
msg: string;
data: T;
};
export type OrgDirectoryNode = {
externalId: string;
parentExternalId?: string;
nodeType: 'GROUP' | 'COMPANY' | 'PROJECT' | 'DEPARTMENT';
name: string;
bindable: boolean;
children: OrgDirectoryNode[];
};
export type OrgDirectoryTree = {
sourceCode: string;
directoryVersion: string;
nodeCount: number;
roots: OrgDirectoryNode[];
};
export type TenantOrgBinding = {
id?: number;
tenantId: string;
tenantName: string;
sourceCode?: string;
rootExternalId?: string;
rootName?: string;
rootNodeType?: string;
mode?: 'EXCLUSIVE' | 'AGGREGATE_ONLY';
status: 'UNBOUND' | 'DRAFT' | 'ACTIVE' | 'DISABLED';
directoryVersion?: string;
lastSyncAt?: string;
};
export type TenantOrgBindingOverview = {
activeTenantCount: number;
boundTenantCount: number;
pendingTenantCount: number;
conflictCount: number;
bindings: TenantOrgBinding[];
};
export type TenantOrgBindingRequest = {
tenantId?: string;
rootExternalId?: string;
mode?: 'EXCLUSIVE' | 'AGGREGATE_ONLY';
status?: 'DRAFT' | 'ACTIVE' | 'DISABLED';
};
export type ScopeConflict = {
tenantId: string;
tenantName: string;
rootExternalId: string;
rootName: string;
reason: string;
};
export type BindingPrecheck = {
canActivate: boolean;
rootName: string;
rootNodeType: string;
conflicts: ScopeConflict[];
};
export type AggregateView = {
bindingId: number;
coveredTenantCount: number;
syncedPersonCount: number;
};
export function getOrgDirectoryTree(): Promise<ApiResult<OrgDirectoryTree>> {
return request({ url: '/api/aihr/platform/org-directory/tree', method: 'get' });
}
export function refreshOrgDirectory(): Promise<ApiResult<{ sourceCode: string; directoryVersion: string; nodeCount: number; warnings: string[] }>> {
return request({ url: '/api/aihr/platform/org-directory/refresh', method: 'post', headers: { repeatSubmit: false } });
}
export function listTenantOrgBindings(): Promise<ApiResult<TenantOrgBindingOverview>> {
return request({ url: '/api/aihr/platform/tenant-org-bindings', method: 'get' });
}
export function precheckTenantOrgBinding(data: TenantOrgBindingRequest): Promise<ApiResult<BindingPrecheck>> {
return request({ url: '/api/aihr/platform/tenant-org-bindings/precheck', method: 'post', data, headers: { repeatSubmit: false } });
}
export function createTenantOrgBinding(data: TenantOrgBindingRequest): Promise<ApiResult<TenantOrgBinding>> {
return request({ url: '/api/aihr/platform/tenant-org-bindings', method: 'post', data, headers: { repeatSubmit: false } });
}
export function updateTenantOrgBinding(id: number, data: TenantOrgBindingRequest): Promise<ApiResult<TenantOrgBinding>> {
return request({ url: `/api/aihr/platform/tenant-org-bindings/${id}`, method: 'patch', data, headers: { repeatSubmit: false } });
}
export function getTenantOrgAggregate(id: number): Promise<ApiResult<AggregateView>> {
return request({ url: `/api/aihr/platform/tenant-org-bindings/${id}/aggregate`, method: 'get' });
}
+5 -2
View File
@@ -17,9 +17,9 @@
</template>
<div class="right-menu flex align-center">
<template v-if="appStore.device !== 'mobile'">
<el-tag v-if="isSuperAdmin && tenantEnabled && !tenantContextVerified" class="mr-2" type="warning" effect="plain"> 租户上下文未确认 </el-tag>
<el-tag v-if="showTenantContextSelector && !tenantContextVerified" class="mr-2" type="warning" effect="plain"> 租户上下文未确认 </el-tag>
<el-select
v-if="isSuperAdmin && tenantEnabled"
v-if="showTenantContextSelector"
v-model="selectedTenantId"
class="min-w-244px mr-2"
clearable
@@ -142,6 +142,9 @@ const { proxy } = getCurrentInstance() as ComponentInternalInstance;
const navType = computed(() => settingsStore.navType);
const showLogo = computed(() => settingsStore.sidebarLogo);
const isSuperAdmin = computed(() => userStore.roles.includes('superadmin'));
const showTenantContextSelector = computed(
() => isSuperAdmin.value && tenantEnabled.value && router.currentRoute.value.path !== '/system/tenant-org-binding'
);
const selectedTenantId = ref<string | number>();
const tenantList = ref<TenantVO[]>([]);
+6
View File
@@ -252,6 +252,12 @@ export const constantRoutes: RouteRecordRaw[] = [
name: 'SystemWebSearch',
meta: { title: '全网AI配置', icon: 'search' }
},
{
path: 'tenant-org-binding',
component: () => import('@/views/system/tenant-org-binding.vue'),
name: 'SystemTenantOrgBinding',
meta: { title: '租户与组织绑定', icon: 'tree', roles: tenantGovernanceRoles }
},
{
path: 'tenant',
component: () => import('@/views/system/tenant/index.vue'),
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
const frontendRoot = existsSync(resolve(process.cwd(), 'src')) ? process.cwd() : resolve(process.cwd(), 'frontend');
const src = (...parts: string[]) => readFileSync(resolve(frontendRoot, 'src', ...parts), 'utf8');
describe('tenant organization binding governance contract', () => {
it('keeps the platform page superadmin-only and exposes the four required interaction states', () => {
const router = src('router', 'index.ts');
const page = src('views', 'system', 'tenant-org-binding.vue');
expect(router).toContain("path: 'tenant-org-binding'");
expect(router).toContain("title: '租户与组织绑定'");
expect(router).toContain('roles: tenantGovernanceRoles');
expect(page).toContain('租户与组织绑定');
expect(page).toContain('执行预检');
expect(page).toContain('发现 ${precheckResult.conflicts.length} 项独占范围冲突');
expect(page).toContain('采用仅汇总方案');
expect(src('layout', 'components', 'Navbar.vue')).toContain("router.currentRoute.value.path !== '/system/tenant-org-binding'");
});
it('does not offer a browser-supplied synchronization scope and labels aggregate-only correctly', () => {
const api = src('api', 'aihr', 'tenant-org-governance.ts');
const page = src('views', 'system', 'tenant-org-binding.vue');
expect(api).toContain('/api/aihr/platform/tenant-org-bindings/precheck');
expect(api).not.toContain('groupId');
expect(api).not.toContain('companyId');
expect(api).not.toContain('departmentId');
expect(page).toContain('仅汇总范围不保存人员明细,且没有组织同步入口');
expect(page).toContain('当前开放 API 只支持集团、公司或部门作为同步边界');
});
});
@@ -0,0 +1,312 @@
<template>
<div class="tenant-org-binding-page">
<section class="page-head">
<div>
<div class="platform-label">平台控制台</div>
<h1>租户与组织绑定</h1>
<p>由平台超级管理员维护租户的数据边界与外部组织范围;租户只可在已授予的独占范围内刷新组织快照。</p>
</div>
<div class="head-actions">
<el-button :icon="Refresh" :loading="directoryRefreshing" @click="refreshDirectory">刷新组织目录</el-button>
<el-button type="primary" :icon="Plus" @click="openBindingDialog()">新建绑定</el-button>
</div>
</section>
<section class="metric-grid">
<article class="metric-card metric-card--blue"><span>有效租户</span><strong>{{ overview?.activeTenantCount ?? 0 }}</strong><small>当前可管理的租户主体</small></article>
<article class="metric-card metric-card--green"><span>已绑定</span><strong>{{ overview?.boundTenantCount ?? 0 }}</strong><small>已建立生效组织范围</small></article>
<article class="metric-card metric-card--amber"><span>待绑定</span><strong>{{ overview?.pendingTenantCount ?? 0 }}</strong><small>尚不可执行组织同步</small></article>
<article class="metric-card metric-card--red"><span>范围冲突</span><strong>{{ overview?.conflictCount ?? 0 }}</strong><small>独占范围不能重叠</small></article>
</section>
<section class="workspace-grid">
<article class="panel tenant-panel">
<div class="panel-title"><div><h2>租户列表</h2><p>选择租户查看其受控组织边界</p></div></div>
<el-input v-model="tenantKeyword" :prefix-icon="Search" clearable placeholder="搜索租户" class="tenant-search" />
<el-table v-loading="loading" :data="filteredBindings" height="510" highlight-current-row @current-change="selectBinding">
<el-table-column prop="tenantName" label="租户" min-width="140" show-overflow-tooltip />
<el-table-column label="状态" width="94">
<template #default="{ row }"><el-tag size="small" :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column label="组织范围" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.rootName || '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="62" align="right">
<template #default="{ row }"><el-button link type="primary" @click.stop="openBindingDialog(row)">{{ row.id ? '调整' : '绑定' }}</el-button></template>
</el-table-column>
</el-table>
</article>
<article class="panel tree-panel">
<div class="panel-title"><div><h2>绑定范围</h2><p>{{ selectedBinding?.rootName || '选择租户后查看组织范围' }}</p></div><el-tag v-if="selectedBinding?.mode" :type="selectedBinding.mode === 'EXCLUSIVE' ? 'success' : 'info'">{{ modeLabel(selectedBinding.mode) }}</el-tag></div>
<el-alert v-if="selectedBinding?.mode === 'EXCLUSIVE'" type="success" :closable="false" show-icon title="该租户可在服务端限定的独占范围内刷新组织快照" />
<el-alert v-else-if="selectedBinding?.mode === 'AGGREGATE_ONLY'" type="info" :closable="false" show-icon title="仅汇总范围不保存人员明细,且没有组织同步入口" />
<el-empty v-if="!directoryTree?.roots?.length" description="尚未加载组织目录,请由平台超级管理员刷新" />
<el-tree v-else :data="directoryTree.roots" node-key="externalId" :props="treeProps" :default-expanded-keys="selectedBinding?.rootExternalId ? [selectedBinding.rootExternalId] : []" highlight-current :expand-on-click-node="false">
<template #default="{ data }"><span class="tree-node"><span class="tree-node__name">{{ data.name }}</span><small>{{ nodeTypeLabel(data.nodeType) }}</small></span></template>
</el-tree>
<div class="tree-footnote">项目节点用于展示集团层级;当前开放组织 API 只支持集团、公司和部门作为可同步边界。</div>
</article>
<aside class="governance-column">
<article class="panel governance-panel">
<div class="panel-title"><div><h2>治理与审计</h2><p>当前操作角色</p></div></div>
<el-tag type="primary" effect="plain"><el-icon><Lock /></el-icon> 平台超级管理员</el-tag>
<ul class="audit-list">
<li><el-icon color="#22a06b"><CircleCheck /></el-icon><span>绑定新建、调整、停用和目录刷新均进入系统操作日志。</span></li>
<li><el-icon color="#1677ff"><InfoFilled /></el-icon><span>范围预检由服务端重新读取目录,不采信浏览器传入的范围参数。</span></li>
<li><el-icon color="#d97706"><WarningFilled /></el-icon><span>独占范围与祖先、子孙或相同节点重叠时禁止生效。</span></li>
</ul>
<el-button v-if="selectedBinding?.id && selectedBinding.status === 'ACTIVE'" plain class="disable-button" @click="disableBinding">停用当前绑定</el-button>
</article>
<article v-if="selectedBinding?.id && selectedBinding.mode === 'AGGREGATE_ONLY'" class="panel aggregate-panel">
<div class="panel-title"><div><h2>同步概览</h2><p>仅授权的聚合指标</p></div><el-button link type="primary" :loading="aggregateLoading" @click="loadAggregate">刷新</el-button></div>
<div class="aggregate-values"><div><strong>{{ aggregate?.coveredTenantCount ?? 0 }}</strong><span>覆盖独占租户</span></div><div><strong>{{ aggregate?.syncedPersonCount ?? 0 }}</strong><span>已同步人员合计</span></div></div>
</article>
</aside>
</section>
<el-dialog v-model="dialogVisible" :title="editingBindingId ? '调整租户组织绑定' : '新建租户组织绑定'" width="760px" destroy-on-close @closed="resetDialog">
<el-steps :active="precheckResult ? 3 : 2" finish-status="success" align-center class="binding-steps"><el-step title="选择租户" /><el-step title="选择组织范围" /><el-step title="冲突预检" /></el-steps>
<el-form label-position="top" class="binding-form">
<div class="form-grid">
<el-form-item label="目标租户" required>
<el-select v-model="bindingForm.tenantId" filterable placeholder="请选择租户" :disabled="Boolean(editingBindingId)">
<el-option v-for="item in tenantOptions" :key="item.tenantId" :label="item.tenantName" :value="item.tenantId" />
</el-select>
</el-form-item>
<el-form-item label="范围模式" required>
<el-radio-group v-model="bindingForm.mode"><el-radio-button label="EXCLUSIVE">独占范围</el-radio-button><el-radio-button label="AGGREGATE_ONLY">仅汇总</el-radio-button></el-radio-group>
</el-form-item>
</div>
<el-form-item label="组织根节点" required>
<el-alert v-if="!directoryTree?.roots?.length" type="warning" :closable="false" title="尚无组织目录,请先关闭窗口并刷新组织目录。" />
<el-tree v-else :data="dialogDirectoryNodes" node-key="externalId" :props="dialogTreeProps" highlight-current :expand-on-click-node="false" @node-click="selectBindingRoot">
<template #default="{ data }"><span class="dialog-tree-node"><span>{{ data.name }}</span><el-tag v-if="!data.bindable" size="small" type="info">仅展示</el-tag><small>{{ nodeTypeLabel(data.nodeType) }}</small></span></template>
</el-tree>
<div v-if="bindingForm.rootExternalId" class="selected-root">已选择:{{ selectedDialogRoot?.name || bindingForm.rootExternalId }}</div>
</el-form-item>
</el-form>
<section v-if="precheckResult" class="precheck-result" :class="{ 'precheck-result--conflict': !precheckResult.canActivate }">
<div class="precheck-title"><el-icon><CircleCheck v-if="precheckResult.canActivate" /><WarningFilled v-else /></el-icon><strong>{{ precheckResult.canActivate ? '范围预检通过,可以生效绑定' : `发现 ${precheckResult.conflicts.length} 项独占范围冲突` }}</strong></div>
<el-table v-if="precheckResult.conflicts.length" :data="precheckResult.conflicts" size="small"><el-table-column prop="tenantName" label="已绑定租户" /><el-table-column prop="rootName" label="冲突组织" /><el-table-column prop="reason" label="冲突原因" min-width="240" /></el-table>
<div v-if="!precheckResult.canActivate && bindingForm.mode === 'EXCLUSIVE'" class="resolution-card"><div><strong>推荐:保留子公司独占租户,集团改为仅汇总</strong><p>子公司保持人员与业务数据隔离;集团仅查看授权聚合指标,不能同步或读取子公司明细。</p></div><el-button type="primary" plain @click="applyAggregateRecommendation">采用仅汇总方案</el-button></div>
</section>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button :loading="prechecking" :disabled="!bindingForm.tenantId || !bindingForm.rootExternalId" @click="precheck">执行预检</el-button>
<el-button type="primary" :loading="saving" :disabled="!precheckResult?.canActivate" @click="saveBinding">确认绑定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup name="TenantOrgBinding" lang="ts">
import { CircleCheck, InfoFilled, Lock, Plus, Refresh, Search, WarningFilled } from '@element-plus/icons-vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref } from 'vue';
import {
createTenantOrgBinding,
getOrgDirectoryTree,
getTenantOrgAggregate,
listTenantOrgBindings,
precheckTenantOrgBinding,
refreshOrgDirectory,
updateTenantOrgBinding,
type AggregateView,
type BindingPrecheck,
type OrgDirectoryNode,
type OrgDirectoryTree,
type TenantOrgBinding,
type TenantOrgBindingOverview
} from '@/api/aihr/tenant-org-governance';
type DialogNode = OrgDirectoryNode & { disabled: boolean; children: DialogNode[] };
const loading = ref(false);
const directoryRefreshing = ref(false);
const overview = ref<TenantOrgBindingOverview>();
const directoryTree = ref<OrgDirectoryTree>();
const selectedBinding = ref<TenantOrgBinding>();
const tenantKeyword = ref('');
const aggregate = ref<AggregateView>();
const aggregateLoading = ref(false);
const dialogVisible = ref(false);
const editingBindingId = ref<number>();
const prechecking = ref(false);
const saving = ref(false);
const precheckResult = ref<BindingPrecheck>();
const bindingForm = reactive({ tenantId: '', rootExternalId: '', mode: 'EXCLUSIVE' as 'EXCLUSIVE' | 'AGGREGATE_ONLY' });
const treeProps = { label: 'name', children: 'children' };
const dialogTreeProps = { label: 'name', children: 'children', disabled: 'disabled' };
const filteredBindings = computed(() => {
const keyword = tenantKeyword.value.trim().toLowerCase();
return (overview.value?.bindings || []).filter((item) => !keyword || `${item.tenantName}${item.tenantId}`.toLowerCase().includes(keyword));
});
const tenantOptions = computed(() => overview.value?.bindings || []);
const dialogDirectoryNodes = computed<DialogNode[]>(() => markDisabled(directoryTree.value?.roots || []));
const selectedDialogRoot = computed(() => findNode(directoryTree.value?.roots || [], bindingForm.rootExternalId));
const statusLabel = (status: TenantOrgBinding['status']) => ({ UNBOUND: '待绑定', DRAFT: '草稿', ACTIVE: '已绑定', DISABLED: '已停用' })[status];
const statusType = (status: TenantOrgBinding['status']) => ({ UNBOUND: 'warning', DRAFT: 'info', ACTIVE: 'success', DISABLED: 'info' })[status] as 'success' | 'warning' | 'info';
const modeLabel = (mode?: string) => mode === 'EXCLUSIVE' ? '独占范围' : '仅汇总';
const nodeTypeLabel = (type: string) => ({ GROUP: '集团', COMPANY: '公司', PROJECT: '项目', DEPARTMENT: '部门' })[type] || type;
const load = async () => {
loading.value = true;
try {
const [tree, bindingList] = await Promise.all([getOrgDirectoryTree(), listTenantOrgBindings()]);
directoryTree.value = tree.data;
overview.value = bindingList.data;
const current = selectedBinding.value && bindingList.data.bindings.find((item) => item.tenantId === selectedBinding.value?.tenantId);
selectedBinding.value = current || bindingList.data.bindings[0];
if (selectedBinding.value?.mode === 'AGGREGATE_ONLY') await loadAggregate();
} catch {
ElMessage.error('租户组织治理数据加载失败,请确认迁移已执行且当前账号为平台超级管理员');
} finally {
loading.value = false;
}
};
const refreshDirectory = async () => {
directoryRefreshing.value = true;
try {
const result = await refreshOrgDirectory();
ElMessage.success(`组织目录已刷新,共 ${result.data.nodeCount} 个节点`);
if (result.data.warnings.length) ElMessage.warning(result.data.warnings.join(';'));
await load();
} catch {
ElMessage.error('组织目录刷新失败,请检查平台连接器配置与授权范围');
} finally {
directoryRefreshing.value = false;
}
};
const selectBinding = (binding?: TenantOrgBinding) => {
selectedBinding.value = binding;
aggregate.value = undefined;
if (binding?.mode === 'AGGREGATE_ONLY') void loadAggregate();
};
const openBindingDialog = (binding?: TenantOrgBinding) => {
editingBindingId.value = binding?.id;
bindingForm.tenantId = binding?.tenantId || '';
bindingForm.rootExternalId = binding?.rootExternalId || '';
bindingForm.mode = binding?.mode || 'EXCLUSIVE';
precheckResult.value = undefined;
dialogVisible.value = true;
};
const selectBindingRoot = (node: DialogNode) => {
if (!node.bindable) {
ElMessage.warning('项目节点仅用于展示;当前开放 API 只支持集团、公司或部门作为同步边界');
return;
}
bindingForm.rootExternalId = node.externalId;
precheckResult.value = undefined;
};
const precheck = async () => {
prechecking.value = true;
try {
const result = await precheckTenantOrgBinding({ ...bindingForm, status: 'ACTIVE' });
precheckResult.value = result.data;
if (result.data.canActivate) ElMessage.success('范围预检通过');
} catch {
ElMessage.error('范围预检失败,请刷新组织目录后重试');
} finally {
prechecking.value = false;
}
};
const applyAggregateRecommendation = async () => {
bindingForm.mode = 'AGGREGATE_ONLY';
precheckResult.value = undefined;
await precheck();
};
const saveBinding = async () => {
saving.value = true;
try {
const payload = { ...bindingForm, status: 'ACTIVE' as const };
const result = editingBindingId.value ? await updateTenantOrgBinding(editingBindingId.value, payload) : await createTenantOrgBinding(payload);
selectedBinding.value = result.data;
ElMessage.success('租户组织绑定已生效');
dialogVisible.value = false;
await load();
} catch {
ElMessage.error('绑定保存失败;若范围已变化,请重新执行预检');
} finally {
saving.value = false;
}
};
const disableBinding = async () => {
const binding = selectedBinding.value;
if (!binding?.id || !binding.rootExternalId || !binding.mode) return;
try {
await ElMessageBox.confirm('停用后该租户不能再执行组织同步,是否继续?', '停用组织绑定', { type: 'warning' });
await updateTenantOrgBinding(binding.id, { tenantId: binding.tenantId, rootExternalId: binding.rootExternalId, mode: binding.mode, status: 'DISABLED' });
ElMessage.success('绑定已停用');
await load();
} catch (error) {
if (error !== 'cancel' && error !== 'close') ElMessage.error('绑定停用失败');
}
};
const loadAggregate = async () => {
if (!selectedBinding.value?.id || selectedBinding.value.mode !== 'AGGREGATE_ONLY') return;
aggregateLoading.value = true;
try {
aggregate.value = (await getTenantOrgAggregate(selectedBinding.value.id)).data;
} catch {
ElMessage.error('汇总指标加载失败');
} finally {
aggregateLoading.value = false;
}
};
const resetDialog = () => {
editingBindingId.value = undefined;
precheckResult.value = undefined;
};
const markDisabled = (nodes: OrgDirectoryNode[]): DialogNode[] => nodes.map((node) => ({ ...node, disabled: !node.bindable, children: markDisabled(node.children || []) }));
const findNode = (nodes: OrgDirectoryNode[], id: string): OrgDirectoryNode | undefined => {
for (const node of nodes) {
if (node.externalId === id) return node;
const found = findNode(node.children || [], id);
if (found) return found;
}
};
onMounted(load);
</script>
<style scoped lang="scss">
.tenant-org-binding-page { min-height: 100%; padding: 22px; background: #f5f7fb; color: #1d2939; }
.page-head, .panel, .metric-card { background: #fff; border: 1px solid #e7ecf3; border-radius: 12px; box-shadow: 0 5px 18px rgb(16 24 40 / 4%); }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 22px 24px; }
.platform-label { display: inline-block; padding: 2px 8px; border-radius: 999px; color: #175cd3; background: #eff8ff; font-size: 12px; font-weight: 600; }
h1, h2, p { margin: 0; }
h1 { margin-top: 7px; font-size: 25px; line-height: 1.2; }
.page-head p, .panel-title p { margin-top: 7px; color: #667085; font-size: 13px; }
.head-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-top: 16px; }
.metric-card { position: relative; overflow: hidden; padding: 17px 18px; }
.metric-card::before { position: absolute; top: 0; left: 0; width: 4px; height: 100%; background: currentcolor; content: ''; }
.metric-card span { display: block; color: #667085; font-size: 13px; }
.metric-card strong { display: block; margin-top: 4px; font-size: 28px; line-height: 1.1; }
.metric-card small { display: block; margin-top: 5px; color: #98a2b3; }
.metric-card--blue { color: #1677ff; }.metric-card--green { color: #12b76a; }.metric-card--amber { color: #f79009; }.metric-card--red { color: #f04438; }
.workspace-grid { display: grid; grid-template-columns: minmax(300px, 1.05fr) minmax(360px, 1.25fr) minmax(260px, .9fr); gap: 16px; margin-top: 16px; align-items: start; }
.panel { padding: 18px; }.panel-title { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }.panel-title h2 { font-size: 17px; }
.tenant-search { margin-bottom: 12px; }.tree-panel :deep(.el-alert) { margin-bottom: 12px; }.tree-panel :deep(.el-tree) { min-height: 370px; }.tree-node, .dialog-tree-node { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }.tree-node__name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.tree-node small, .dialog-tree-node small { color: #98a2b3; font-size: 12px; }.tree-footnote { margin-top: 12px; padding: 10px 12px; border-radius: 8px; color: #7a5d00; background: #fff9e8; font-size: 12px; line-height: 1.6; }
.governance-column { display: grid; gap: 16px; }.governance-panel :deep(.el-tag .el-icon) { margin-right: 4px; }.audit-list { display: grid; gap: 14px; padding: 0; margin: 18px 0; list-style: none; }.audit-list li { display: flex; gap: 9px; color: #475467; font-size: 13px; line-height: 1.55; }.audit-list .el-icon { flex: 0 0 auto; margin-top: 2px; }.disable-button { width: 100%; }.aggregate-values { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }.aggregate-values div { padding: 12px; border-radius: 8px; background: #f8fbff; }.aggregate-values strong { display: block; color: #175cd3; font-size: 22px; }.aggregate-values span { color: #667085; font-size: 12px; }
.binding-steps { margin: 0 0 22px; }.binding-form :deep(.el-form-item) { margin-bottom: 18px; }.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }.form-grid :deep(.el-select) { width: 100%; }.binding-form :deep(.el-tree) { max-height: 220px; padding: 10px; overflow: auto; border: 1px solid #e4e7ed; border-radius: 8px; }.dialog-tree-node { width: 100%; justify-content: space-between; padding-right: 10px; }.selected-root { margin-top: 8px; color: #175cd3; font-size: 13px; }.precheck-result { padding: 14px; border: 1px solid #b7ebc6; border-radius: 10px; background: #f6ffed; }.precheck-result--conflict { border-color: #ffccc7; background: #fff2f0; }.precheck-title { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; color: #1d6f42; }.precheck-result--conflict .precheck-title { color: #cf1322; }.resolution-card { display: flex; align-items: center; justify-content: space-between; gap: 14px; margin-top: 12px; padding: 13px; border: 1px solid #91caff; border-radius: 8px; background: #f0f7ff; }.resolution-card p { margin-top: 5px; color: #475467; font-size: 12px; line-height: 1.55; }
@media (max-width: 1280px) { .workspace-grid { grid-template-columns: 1fr 1fr; }.governance-column { grid-column: span 2; grid-template-columns: 1fr 1fr; }.metric-grid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 760px) { .tenant-org-binding-page { padding: 12px; }.page-head, .resolution-card { align-items: flex-start; flex-direction: column; }.workspace-grid, .governance-column, .metric-grid, .form-grid { grid-template-columns: 1fr; }.governance-column { grid-column: auto; }.head-actions { width: 100%; }.head-actions .el-button { flex: 1; }.binding-steps :deep(.el-step__title) { font-size: 11px; } }
</style>