feat(permission): replace OA roles with post-bound roles and assignee posts

Unify effective roles via post-role bindings, remove dedicated OA role UI, and update OA assignee resolution plus related SQL/docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-04 17:41:04 +08:00
1 parent a3c73d76bb
commit 06db3bc966
66 files changed
+55337 -38647

No files matched your search

@@ -228,7 +228,7 @@
function resolveSourceText(src: string) {
const map: Record<string, string> = {
users: '指定成员',
oa_roles: '工作流角色',
posts: '职位',
dept_leader: '部门负责人',
direct_supervisor: '直属主管',
supervisor_fallback_dept_leader: '部门负责人(主管回退)',
@@ -83,72 +83,78 @@
/>
</div>
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">开始日期</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.startDate || '—' }}</span>
</template>
<el-date-picker
v-else
:model-value="leg.startDate || undefined"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
@update:model-value="(v) => onScheduleChange(idx, 'startDate', v || '')"
/>
</div>
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">开始时段</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.startHalf || '—' }}</span>
</template>
<el-select
v-else
:model-value="leg.startHalf"
@update:model-value="(v) => onScheduleChange(idx, 'startHalf', String(v || ''))"
>
<el-option
v-for="opt in halfOpts"
:key="opt.value"
:label="opt.label"
:value="opt.value"
<div class="oa-trip-leg__row">
<div class="oa-trip-leg__field oa-trip-leg__field--date">
<label class="oa-trip-leg__label">开始日期</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.startDate || '—' }}</span>
</template>
<el-date-picker
v-else
:model-value="leg.startDate || undefined"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
@update:model-value="(v) => onScheduleChange(idx, 'startDate', v || '')"
/>
</el-select>
</div>
<div class="oa-trip-leg__field oa-trip-leg__field--half">
<label class="oa-trip-leg__label">开始时段</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.startHalf || '—' }}</span>
</template>
<el-select
v-else
class="oa-trip-leg__half-select"
:model-value="leg.startHalf"
@update:model-value="(v) => onScheduleChange(idx, 'startHalf', String(v || ''))"
>
<el-option
v-for="opt in halfOpts"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
</div>
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">结束日期</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.endDate || '—' }}</span>
</template>
<el-date-picker
v-else
:model-value="leg.endDate || undefined"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
@update:model-value="(v) => onScheduleChange(idx, 'endDate', v || '')"
/>
</div>
<div class="oa-trip-leg__field">
<label class="oa-trip-leg__label">结束时段</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.endHalf || '—' }}</span>
</template>
<el-select
v-else
:model-value="leg.endHalf"
@update:model-value="(v) => onScheduleChange(idx, 'endHalf', String(v || ''))"
>
<el-option
v-for="opt in halfOpts"
:key="opt.value"
:label="opt.label"
:value="opt.value"
<div class="oa-trip-leg__row">
<div class="oa-trip-leg__field oa-trip-leg__field--date">
<label class="oa-trip-leg__label">结束日期</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.endDate || '—' }}</span>
</template>
<el-date-picker
v-else
:model-value="leg.endDate || undefined"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
@update:model-value="(v) => onScheduleChange(idx, 'endDate', v || '')"
/>
</el-select>
</div>
<div class="oa-trip-leg__field oa-trip-leg__field--half">
<label class="oa-trip-leg__label">结束时段</label>
<template v-if="disabled">
<span class="oa-trip-leg__readonly">{{ leg.endHalf || '—' }}</span>
</template>
<el-select
v-else
class="oa-trip-leg__half-select"
:model-value="leg.endHalf"
@update:model-value="(v) => onScheduleChange(idx, 'endHalf', String(v || ''))"
>
<el-option
v-for="opt in halfOpts"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</div>
</div>
<div class="oa-trip-leg__field oa-trip-leg__field--duration">
@@ -383,16 +389,28 @@
.oa-trip-leg__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.oa-trip-leg__row {
display: flex;
gap: 12px;
align-items: flex-start;
min-width: 0;
}
.oa-trip-leg__field {
min-width: 0;
}
.oa-trip-leg__field--duration {
grid-column: 1 / -1;
.oa-trip-leg__field--date {
flex: 1 1 auto;
}
.oa-trip-leg__field--half {
flex: 0 0 120px;
width: 120px;
}
.oa-trip-leg__label {
@@ -443,18 +461,13 @@
max-width: 100%;
}
.oa-trip-leg__half-select {
width: 120px;
max-width: 100%;
}
.oa-trip-leg :deep(.el-input__wrapper),
.oa-trip-leg :deep(.el-select__wrapper) {
min-height: 36px;
}
@media (max-width: 640px) {
.oa-trip-leg__grid {
grid-template-columns: 1fr;
}
.oa-trip-leg__field--duration {
grid-column: auto;
}
}
</style>
@@ -156,109 +156,77 @@
</div>
</el-form-item>
<el-form-item v-if="showRolesPicker">
<el-form-item v-if="showPostsPicker">
<template #label>
工作流角色
<span class="form-label-tip">可多选,已选角色成员将作为本节点{{ editing.type === 'cc' ? '抄送' : '审批' }}人</span>
职位
<span class="form-label-tip">可多选,已选职位关联用户将作为本节点{{ editing.type === 'cc' ? '抄送' : '审批' }}人</span>
</template>
<div v-loading="roleLoading" class="member-picker">
<div v-loading="postLoading" class="member-picker">
<el-select
v-model="rolePickBuffer"
v-model="postPickBuffer"
filterable
clearable
:loading="roleLoading"
placeholder="搜索角色名称 / 编码,点击添加"
:loading="postLoading"
placeholder="搜索职位名称 / 编码,点击添加"
style="width: 100%"
popper-class="oa-flow-member-select-popper"
@change="onRolePick"
@change="onPostPick"
>
<el-option-group v-if="presetRoles.length" label="预置">
<el-option
v-for="r in presetRoles"
:key="r.id"
:label="roleOptionLabel(r)"
:value="r.id"
:disabled="(cfg.oaRoleIds || []).includes(r.id)"
>
<div class="member-option">
<span class="member-avatar" :style="{ background: avatarColor(r.id) }">
{{ avatarInitial(r.roleName) }}
<el-option
v-for="p in postOptions"
:key="p.id"
:label="postOptionLabel(p)"
:value="p.id"
:disabled="(cfg.postIds || []).includes(p.id)"
>
<div class="member-option">
<span class="member-avatar" :style="{ background: avatarColor(p.id) }">
{{ avatarInitial(p.postName) }}
</span>
<span class="member-option__meta">
<span class="member-option__name">{{ p.postName || '未命名' }}</span>
<span v-if="p.postCode" class="member-option__sub">
编码 {{ p.postCode }}
</span>
<span class="member-option__meta">
<span class="member-option__name">{{ r.roleName || '未命名' }}</span>
<span v-if="r.roleCode" class="member-option__sub">
编码 {{ r.roleCode }}
</span>
</span>
<el-tag
v-if="(cfg.oaRoleIds || []).includes(r.id)"
size="small"
type="info"
effect="plain"
class="member-option__tag"
>
已选
</el-tag>
</div>
</el-option>
</el-option-group>
<el-option-group v-if="customRoles.length" label="自定义">
<el-option
v-for="r in customRoles"
:key="r.id"
:label="roleOptionLabel(r)"
:value="r.id"
:disabled="(cfg.oaRoleIds || []).includes(r.id)"
>
<div class="member-option">
<span class="member-avatar" :style="{ background: avatarColor(r.id) }">
{{ avatarInitial(r.roleName) }}
</span>
<span class="member-option__meta">
<span class="member-option__name">{{ r.roleName || '未命名' }}</span>
<span v-if="r.roleCode" class="member-option__sub">
编码 {{ r.roleCode }}
</span>
</span>
<el-tag
v-if="(cfg.oaRoleIds || []).includes(r.id)"
size="small"
type="info"
effect="plain"
class="member-option__tag"
>
已选
</el-tag>
</div>
</el-option>
</el-option-group>
</span>
<el-tag
v-if="(cfg.postIds || []).includes(p.id)"
size="small"
type="info"
effect="plain"
class="member-option__tag"
>
已选
</el-tag>
</div>
</el-option>
</el-select>
<div
class="member-chips"
:class="{ 'is-empty': !(cfg.oaRoleIds && cfg.oaRoleIds.length) }"
:class="{ 'is-empty': !(cfg.postIds && cfg.postIds.length) }"
>
<template v-if="cfg.oaRoleIds && cfg.oaRoleIds.length">
<span v-for="id in cfg.oaRoleIds" :key="id" class="member-chip">
<template v-if="cfg.postIds && cfg.postIds.length">
<span v-for="id in cfg.postIds" :key="id" class="member-chip">
<span class="member-avatar member-avatar--sm" :style="{ background: avatarColor(id) }">
{{ avatarInitial(resolveRole(id)?.roleName) }}
{{ avatarInitial(resolvePost(id)?.postName) }}
</span>
<span class="member-chip__name" :title="roleChipTitle(id)">
{{ resolveRole(id)?.roleName || id }}
<span class="member-chip__name" :title="postChipTitle(id)">
{{ resolvePost(id)?.postName || id }}
</span>
<button
type="button"
class="member-chip__rm"
:aria-label="`移除 ${resolveRole(id)?.roleName || id}`"
@click="removeRole(id)"
:aria-label="`移除 ${resolvePost(id)?.postName || id}`"
@click="removePost(id)"
>
×
</button>
</span>
</template>
<template v-else>暂未选择角色,请从上方搜索添加</template>
<template v-else>暂未选择职位,请从上方搜索添加</template>
</div>
<p class="member-picked-count">已选 {{ cfg.oaRoleIds?.length || 0 }} 个角色</p>
<p class="member-picked-count">已选 {{ cfg.postIds?.length || 0 }} 个职位</p>
</div>
</el-form-item>
@@ -330,8 +298,7 @@
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Close, Check, UserFilled } from '@element-plus/icons-vue'
import { fetchUserList } from '@/api/admin'
import { fetchOaRoleList } from '@/api/oaApproval'
import { fetchUserList, fetchPostList } from '@/api/admin'
import { MAX_ADMIN_LIST_PAGE_SIZE } from '@/utils/fetch-paged-list'
import type { ConditionCandidate } from '@/components/oa-approval/oa-condition-fields'
import ConditionRuleEditor from './ConditionRuleEditor.vue'
@@ -396,13 +363,13 @@
assigneeType: 'dept_leader',
assigneeTypes: [],
userIds: [],
oaRoleIds: [],
postIds: [],
signMode: 'or'
})
const assigneeTypeOptions: Array<{ label: string; value: AssigneeType }> = [
{ label: '指定成员', value: 'users' },
{ label: '工作流角色', value: 'oa_roles' },
{ label: '职位', value: 'posts' },
{ label: '部门负责人', value: 'dept_leader' },
{ label: '发起人主管', value: 'direct_supervisor' },
{ label: '发起人自选', value: 'initiator_select' }
@@ -412,9 +379,9 @@
if (editing.value?.type === 'cc') return (cfg.assigneeTypes || []).includes('users')
return cfg.assigneeType === 'users'
})
const showRolesPicker = computed(() => {
if (editing.value?.type === 'cc') return (cfg.assigneeTypes || []).includes('oa_roles')
return cfg.assigneeType === 'oa_roles'
const showPostsPicker = computed(() => {
if (editing.value?.type === 'cc') return (cfg.assigneeTypes || []).includes('posts')
return cfg.assigneeType === 'posts'
})
const drawerTitle = computed(() => {
@@ -435,19 +402,13 @@
return '配置流程节点'
})
const roleLoading = ref(false)
const postLoading = ref(false)
const userLoading = ref(false)
const roleOptions = ref<any[]>([])
type PostOption = { id: string; postName: string; postCode: string }
const postOptions = ref<PostOption[]>([])
const userOptions = ref<UserOption[]>([])
const memberPickBuffer = ref('')
const rolePickBuffer = ref('')
const presetRoles = computed(() =>
roleOptions.value.filter((r) => r.roleKind === 'preset' || ['GM', 'CHAIRMAN'].includes(r.roleCode))
)
const customRoles = computed(() =>
roleOptions.value.filter((r) => r.roleKind !== 'preset' && !['GM', 'CHAIRMAN'].includes(r.roleCode))
)
const postPickBuffer = ref('')
function avatarColor(id: string) {
let hash = 0
@@ -464,8 +425,8 @@
return userOptions.value.find((u) => u.id === id)
}
function resolveRole(id: string) {
return roleOptions.value.find((r) => r.id === id)
function resolvePost(id: string) {
return postOptions.value.find((p) => p.id === id)
}
function chipTitle(id: string) {
@@ -473,16 +434,16 @@
return u ? memberOptionLabel(u) : id
}
function roleOptionLabel(r: { roleName?: string; roleCode?: string }) {
const name = (r.roleName || '').trim() || '未命名'
const code = (r.roleCode || '').trim()
function postOptionLabel(p: { postName?: string; postCode?: string }) {
const name = (p.postName || '').trim() || '未命名'
const code = (p.postCode || '').trim()
if (code && code !== name) return `${name}(${code})`
return name
}
function roleChipTitle(id: string) {
const r = resolveRole(id)
return r ? roleOptionLabel(r) : id
function postChipTitle(id: string) {
const p = resolvePost(id)
return p ? postOptionLabel(p) : id
}
function memberOptionLabel(u: UserOption) {
@@ -514,18 +475,18 @@
cfg.userIds = cfg.userIds.filter((x) => x !== id)
}
function onRolePick(id: string) {
function onPostPick(id: string) {
if (!id) return
if (!cfg.oaRoleIds) cfg.oaRoleIds = []
if (!cfg.oaRoleIds.includes(id)) cfg.oaRoleIds.push(id)
if (!cfg.postIds) cfg.postIds = []
if (!cfg.postIds.includes(id)) cfg.postIds.push(id)
nextTick(() => {
rolePickBuffer.value = ''
postPickBuffer.value = ''
})
}
function removeRole(id: string) {
if (!cfg.oaRoleIds) return
cfg.oaRoleIds = cfg.oaRoleIds.filter((x) => x !== id)
function removePost(id: string) {
if (!cfg.postIds) return
cfg.postIds = cfg.postIds.filter((x) => x !== id)
}
async function setAssigneeType(type: AssigneeType) {
@@ -542,19 +503,33 @@
cfg.assigneeTypes = cur
if (cur[0]) cfg.assigneeType = cur[0]
if (cur.includes('users')) await loadUsers()
if (cur.includes('oa_roles')) await loadRoles()
if (cur.includes('posts')) await loadPosts()
return
}
await setAssigneeType(type)
}
async function loadRoles() {
roleLoading.value = true
async function loadPosts() {
postLoading.value = true
try {
const res = await fetchOaRoleList({ status: '1' })
roleOptions.value = res?.list || []
const pageSize = MAX_ADMIN_LIST_PAGE_SIZE
const raw: any[] = []
let page = 1
for (;;) {
const res = await fetchPostList({ page, pageSize })
const list = (res?.list || []).filter((p: any) => p.status === '1')
raw.push(...list)
if ((res?.list || []).length < pageSize) break
if (typeof res?.total === 'number' && raw.length >= res.total) break
page += 1
}
postOptions.value = raw.map((p) => ({
id: p.id,
postName: p.postName || '',
postCode: p.postCode || ''
}))
} finally {
roleLoading.value = false
postLoading.value = false
}
}
@@ -684,18 +659,18 @@
cfg.assigneeType = ac.assigneeType || 'dept_leader'
}
cfg.userIds = [...(ac.userIds || [])]
cfg.oaRoleIds = [...(ac.oaRoleIds || [])]
cfg.postIds = [...(ac.postIds || [])]
cfg.signMode = ac.signMode || 'or'
memberPickBuffer.value = ''
rolePickBuffer.value = ''
if (showUsersPicker.value) await loadUsers()
if (showRolesPicker.value) await loadRoles()
if (showPostsPicker.value) await loadPosts()
drawerVisible.value = true
}
async function onAssigneeTypeChange() {
if (cfg.assigneeType === 'users' && !userOptions.value.length) await loadUsers()
if (cfg.assigneeType === 'oa_roles' && !roleOptions.value.length) await loadRoles()
if (cfg.assigneeType === 'posts' && !postOptions.value.length) await loadPosts()
}
function onDefaultChange(idx: number, v: boolean) {
@@ -750,21 +725,21 @@
ElMessage.warning('已勾选指定成员,请选择至少一名成员')
return
}
if (types.includes('oa_roles') && !(cfg.oaRoleIds || []).length) {
ElMessage.warning('已勾选工作流角色,请选择至少一个角色')
if (types.includes('posts') && !(cfg.postIds || []).length) {
ElMessage.warning('已勾选职位,请选择至少一个职位')
return
}
live.approverConfig = {
assigneeTypes: types,
assigneeType: types[0],
userIds: [...(cfg.userIds || [])],
oaRoleIds: [...(cfg.oaRoleIds || [])]
postIds: [...(cfg.postIds || [])]
}
} else if (live.type === 'approval') {
live.approverConfig = {
assigneeType: cfg.assigneeType,
userIds: cfg.assigneeType === 'users' ? [...(cfg.userIds || [])] : undefined,
oaRoleIds: cfg.assigneeType === 'oa_roles' ? [...(cfg.oaRoleIds || [])] : undefined,
postIds: cfg.assigneeType === 'posts' ? [...(cfg.postIds || [])] : undefined,
signMode: cfg.signMode || 'or'
}
}
@@ -2,7 +2,7 @@ export type FlowNodeType = 'start' | 'approval' | 'cc' | 'condition' | 'end'
export type AssigneeType =
| 'users'
| 'oa_roles'
| 'posts'
| 'dept_leader'
| 'direct_supervisor'
| 'initiator_select'
@@ -13,8 +13,7 @@ export interface ApproverConfig {
/** 抄送人:多选选人方式(优先)。审批人:不使用 */
assigneeTypes?: AssigneeType[]
userIds?: string[]
oaRoleIds?: string[]
oaRoleCodes?: string[]
postIds?: string[]
signMode?: 'or' | 'and'
}
@@ -126,7 +125,7 @@ export function ensureFlowRoot(raw: unknown): FlowNode {
const VALID_ASSIGNEE: AssigneeType[] = [
'users',
'oa_roles',
'posts',
'dept_leader',
'direct_supervisor',
'initiator_select'
@@ -134,7 +133,7 @@ const VALID_ASSIGNEE: AssigneeType[] = [
const ASSIGNEE_LABEL: Record<AssigneeType, string> = {
users: '指定成员',
oa_roles: '工作流角色',
posts: '职位',
dept_leader: '发起人部门负责人',
direct_supervisor: '发起人的主管',
initiator_select: '发起人自选'
@@ -170,8 +169,8 @@ export function summarizeApprover(cfg?: ApproverConfig, nodeType?: FlowNodeType)
const n = cfg?.userIds?.length || 0
return n ? `${base}(${n}人)` : `${base}(未选)`
}
if (t === 'oa_roles') {
const n = (cfg?.oaRoleIds?.length || 0) + (cfg?.oaRoleCodes?.length || 0)
if (t === 'posts') {
const n = cfg?.postIds?.length || 0
return n ? `${base}(已选${n})` : `${base}(未选)`
}
return base
@@ -185,8 +184,8 @@ export function summarizeApprover(cfg?: ApproverConfig, nodeType?: FlowNodeType)
const n = cfg.userIds?.length || 0
return n ? `${base}(${n}人)` : `${base}(未选)`
}
if (cfg.assigneeType === 'oa_roles') {
const n = (cfg.oaRoleIds?.length || 0) + (cfg.oaRoleCodes?.length || 0)
if (cfg.assigneeType === 'posts') {
const n = cfg.postIds?.length || 0
return n ? `${base}(已选${n})` : `${base}(未选)`
}
const sign =