375 lines
12 KiB
Vue
375 lines
12 KiB
Vue
<template>
|
||
<div class="org-permission-page">
|
||
<section class="page-head">
|
||
<div>
|
||
<h1>组织与权限</h1>
|
||
<p>把组织人员快照、岗位映射和项目数据范围放到同一张运营视图,先支撑陪练试点权限边界。</p>
|
||
</div>
|
||
<div class="head-actions">
|
||
<el-switch v-model="dryRun" active-text="预检" inactive-text="写入" />
|
||
<el-button type="danger" :icon="Refresh" :loading="syncing" @click="runSync">同步预检</el-button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="metric-grid">
|
||
<article v-for="item in metrics" :key="item.label" class="metric-card">
|
||
<span>{{ item.label }}</span>
|
||
<strong>{{ item.value }}</strong>
|
||
<small>{{ item.note }}</small>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="panel org-panel">
|
||
<div class="panel-title">
|
||
<h2>组织人员快照</h2>
|
||
<div class="snapshot-stats">
|
||
<el-tag>总数 {{ orgSnapshot?.total ?? 0 }}</el-tag>
|
||
<el-tag type="success">在职 {{ orgSnapshot?.active ?? 0 }}</el-tag>
|
||
<el-tag type="info">离职 {{ orgSnapshot?.departed ?? 0 }}</el-tag>
|
||
</div>
|
||
</div>
|
||
<div class="org-filters">
|
||
<el-input v-model="orgFilters.keyword" clearable placeholder="姓名/岗位/部门/外部ID" @keyup.enter="searchOrgSnapshot" />
|
||
<el-select v-model="orgFilters.projectCode" clearable placeholder="项目">
|
||
<el-option v-for="item in orgSnapshot?.projects || []" :key="item.projectCode" :label="item.projectName" :value="item.projectCode" />
|
||
</el-select>
|
||
<el-select v-model="orgFilters.positionLevel" clearable placeholder="层级">
|
||
<el-option v-for="item in orgSnapshot?.levels || []" :key="item" :label="item" :value="item" />
|
||
</el-select>
|
||
<el-select v-model="orgFilters.status" clearable placeholder="状态">
|
||
<el-option label="在职" value="active" />
|
||
<el-option label="离职" value="departed" />
|
||
</el-select>
|
||
<el-button :icon="Refresh" :loading="orgLoading" @click="searchOrgSnapshot">查询</el-button>
|
||
</div>
|
||
<el-table v-loading="orgLoading" :data="orgSnapshot?.rows || []" size="small" empty-text="暂无组织人员快照">
|
||
<el-table-column prop="projectName" label="项目" min-width="150" show-overflow-tooltip />
|
||
<el-table-column prop="deptName" label="部门/条线" min-width="130" show-overflow-tooltip />
|
||
<el-table-column prop="personName" label="姓名" width="110" />
|
||
<el-table-column prop="positionName" label="岗位" min-width="140" show-overflow-tooltip />
|
||
<el-table-column prop="positionLevel" label="层级" width="100" />
|
||
<el-table-column prop="employmentStatus" label="状态" width="90">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.employmentStatus === 'active' ? 'success' : 'info'" size="small">
|
||
{{ row.employmentStatus === 'active' ? '在职' : '离职' }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="extPartyId" label="外部主体ID" min-width="150" show-overflow-tooltip />
|
||
<el-table-column prop="snapshotDate" label="快照日期" width="110" />
|
||
</el-table>
|
||
<pagination
|
||
v-show="(orgSnapshot?.total || 0) > 0"
|
||
v-model:page="orgPage.pageNum"
|
||
v-model:limit="orgPage.pageSize"
|
||
:total="orgSnapshot?.total || 0"
|
||
@pagination="loadOrgSnapshot"
|
||
/>
|
||
</section>
|
||
|
||
<section class="main-grid">
|
||
<article class="panel">
|
||
<div class="panel-title">
|
||
<h2>身份三轴</h2>
|
||
</div>
|
||
<el-table :data="identityAxes" size="small">
|
||
<el-table-column prop="axis" label="轴" width="120" />
|
||
<el-table-column prop="source" label="当前数据源" width="180" />
|
||
<el-table-column prop="example" label="例子" min-width="200" />
|
||
<el-table-column prop="status" label="状态" width="110">
|
||
<template #default="{ row }">
|
||
<el-tag :type="row.type">{{ row.status }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</article>
|
||
|
||
<article class="panel">
|
||
<div class="panel-title">
|
||
<h2>角色映射</h2>
|
||
</div>
|
||
<el-table :data="roleMappings" size="small">
|
||
<el-table-column prop="match" label="匹配" min-width="150" />
|
||
<el-table-column prop="role" label="本地角色" width="130" />
|
||
<el-table-column prop="scope" label="数据范围" min-width="160" />
|
||
<el-table-column prop="owner" label="负责人" width="100" />
|
||
</el-table>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="main-grid">
|
||
<article class="panel">
|
||
<div class="panel-title">
|
||
<h2>项目范围</h2>
|
||
</div>
|
||
<el-table :data="projectScopes" size="small" empty-text="组织同步后展示真实项目范围">
|
||
<el-table-column prop="project" label="项目" min-width="150" />
|
||
<el-table-column prop="type" label="业态" width="90" />
|
||
<el-table-column prop="supervisor" label="主管" width="100" />
|
||
<el-table-column prop="visibleData" label="可见数据" min-width="210" />
|
||
</el-table>
|
||
</article>
|
||
|
||
<article class="panel sync-panel">
|
||
<div class="panel-title">
|
||
<h2>同步结果</h2>
|
||
</div>
|
||
<div v-if="syncResult" class="sync-result">
|
||
<div>
|
||
<span>模式</span><strong>{{ syncResult.dryRun ? '预检' : '写入' }}</strong>
|
||
</div>
|
||
<div>
|
||
<span>来源</span><strong>{{ syncResult.source }}</strong>
|
||
</div>
|
||
<div>
|
||
<span>公司/部门/员工</span>
|
||
<strong>{{ syncResult.companyCount }} / {{ syncResult.departmentCount }} / {{ syncResult.employeeCount }}</strong>
|
||
</div>
|
||
<div>
|
||
<span>写入/跳过</span><strong>{{ syncResult.syncedCount }} / {{ syncResult.skippedCount }}</strong>
|
||
</div>
|
||
<p v-if="syncResult.warnings?.length">{{ syncResult.warnings.join(';') }}</p>
|
||
<p v-else>同步接口已返回结果。</p>
|
||
</div>
|
||
<el-empty v-else description="尚未执行同步预检" />
|
||
</article>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup name="OrgPermission" lang="ts">
|
||
import { Refresh } from '@element-plus/icons-vue';
|
||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||
import { computed, onMounted, reactive, ref } from 'vue';
|
||
import { getOrgSnapshot, syncOrgSnapshot, type OrgSnapshotResponse, type OrgSyncResponse } from '@/api/aihr/mobile-admin';
|
||
|
||
const dryRun = ref(true);
|
||
const syncing = ref(false);
|
||
const syncResult = ref<OrgSyncResponse | null>(null);
|
||
const orgLoading = ref(false);
|
||
const orgSnapshot = ref<OrgSnapshotResponse | null>(null);
|
||
const orgFilters = reactive({
|
||
keyword: '',
|
||
projectCode: '',
|
||
positionLevel: '',
|
||
status: 'active'
|
||
});
|
||
const orgPage = reactive({
|
||
pageNum: 1,
|
||
pageSize: 20
|
||
});
|
||
|
||
const metrics = [
|
||
{ label: '权威组织源', value: '快照', note: '阶段三前用 aihr_org_snapshot' },
|
||
{ label: '数据范围主体', value: '项目', note: '主管只看本项目训练/复盘' },
|
||
{ label: '试点岗位', value: '管家(生活顾问)', note: 'position 标签沿用现有值' },
|
||
{ label: 'SSO', value: '暂缓', note: '北森对接归阶段三' }
|
||
];
|
||
|
||
const identityAxes = [
|
||
{ axis: '横向条线', source: 'position_code', example: '管家/客服、工程、保洁、保安', status: '已定义', type: 'success' },
|
||
{ axis: '纵向层级', source: '岗位/组织映射', example: '一线、主管、项目经理、片区经理', status: '最小集', type: 'warning' },
|
||
{ axis: '管辖范围', source: '组织树 + 项目', example: '单项目 / 多项目 / 城市 / 集团', status: 'P0', type: 'danger' }
|
||
];
|
||
|
||
const roleMappings = [
|
||
{ match: 'position = 生活顾问', role: '学员', scope: '本人训练与今日安排', owner: 'HR' },
|
||
{ match: 'position = 项目主管', role: '主管', scope: '本项目团队训练与复盘', owner: '项目经理' },
|
||
{ match: 'role = hr_operator', role: 'HR运营', scope: '场景、Rubric、候选资料审核', owner: 'HR' },
|
||
{ match: 'admin', role: '管理员', scope: '模型、资料处理、组织同步', owner: '技术' }
|
||
];
|
||
|
||
const projectScopes = computed(() =>
|
||
(orgSnapshot.value?.projects || []).slice(0, 8).map((item) => ({
|
||
project: item.projectName,
|
||
type: '住宅',
|
||
supervisor: '按组织层级',
|
||
visibleData: '本项目员工、训练记录、复盘与画像'
|
||
}))
|
||
);
|
||
|
||
const loadOrgSnapshot = async () => {
|
||
orgLoading.value = true;
|
||
try {
|
||
const result = await getOrgSnapshot({ ...orgFilters, pageNum: orgPage.pageNum, pageSize: orgPage.pageSize });
|
||
orgSnapshot.value = result.data;
|
||
} catch (error) {
|
||
ElMessage.error('组织人员快照加载失败');
|
||
} finally {
|
||
orgLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const searchOrgSnapshot = () => {
|
||
orgPage.pageNum = 1;
|
||
loadOrgSnapshot();
|
||
};
|
||
|
||
const runSync = async () => {
|
||
if (!dryRun.value) {
|
||
await ElMessageBox.confirm('写入模式会覆盖当前租户组织快照,确认继续吗?', '组织同步', { type: 'warning' });
|
||
}
|
||
syncing.value = true;
|
||
try {
|
||
const result = await syncOrgSnapshot({ dryRun: dryRun.value, replaceExisting: true, pageSize: 500 });
|
||
syncResult.value = result.data;
|
||
ElMessage.success(dryRun.value ? '组织同步预检完成' : '组织快照已同步');
|
||
if (!dryRun.value) {
|
||
await loadOrgSnapshot();
|
||
}
|
||
} catch (error) {
|
||
ElMessage.error('组织同步接口不可用,请检查外部同步配置');
|
||
} finally {
|
||
syncing.value = false;
|
||
}
|
||
};
|
||
|
||
onMounted(loadOrgSnapshot);
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.org-permission-page {
|
||
min-height: 100%;
|
||
padding: 24px;
|
||
background: #f5f7fb;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.page-head,
|
||
.panel,
|
||
.metric-card {
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
}
|
||
|
||
.page-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 20px 22px;
|
||
|
||
h1 {
|
||
margin: 0 0 8px;
|
||
font-size: 24px;
|
||
}
|
||
|
||
p {
|
||
margin: 0;
|
||
color: #667085;
|
||
}
|
||
}
|
||
|
||
.head-actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.metric-grid,
|
||
.main-grid {
|
||
display: grid;
|
||
gap: 16px;
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.metric-grid {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
|
||
.main-grid {
|
||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.8fr);
|
||
}
|
||
|
||
.metric-card {
|
||
padding: 16px;
|
||
|
||
span,
|
||
small {
|
||
display: block;
|
||
color: #667085;
|
||
}
|
||
|
||
strong {
|
||
display: block;
|
||
margin: 8px 0;
|
||
font-size: 22px;
|
||
}
|
||
}
|
||
|
||
.panel {
|
||
padding: 18px;
|
||
}
|
||
|
||
.panel-title h2 {
|
||
margin: 0 0 14px;
|
||
font-size: 17px;
|
||
}
|
||
|
||
.org-panel {
|
||
margin-top: 16px;
|
||
}
|
||
|
||
.org-panel .panel-title {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
|
||
.snapshot-stats {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.org-filters {
|
||
display: grid;
|
||
grid-template-columns: minmax(220px, 1fr) repeat(3, minmax(130px, 0.4fr)) auto;
|
||
gap: 10px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.sync-result {
|
||
display: grid;
|
||
gap: 10px;
|
||
|
||
div {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
border-bottom: 1px solid #eef2f7;
|
||
padding-bottom: 8px;
|
||
}
|
||
|
||
span {
|
||
color: #667085;
|
||
}
|
||
|
||
p {
|
||
margin: 8px 0 0;
|
||
color: #475467;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 980px) {
|
||
.page-head,
|
||
.main-grid,
|
||
.org-filters {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.metric-grid {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
|
||
.page-head {
|
||
display: block;
|
||
}
|
||
|
||
.head-actions {
|
||
margin-top: 14px;
|
||
}
|
||
}
|
||
</style>
|