Files
prop-ai-hr/frontend/src/views/admin/org-permission.vue
T

375 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>