430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
export type MaterialPermissionFlags = {
|
||
canView: boolean
|
||
canDownload: boolean
|
||
canDownloadSource: boolean
|
||
canShare: boolean
|
||
/** 任务-详情单图编辑(替换/改名/删除):仅对「任务-详情」类型生效,其余类型忽略 */
|
||
canEditAssets: boolean
|
||
/** 私密任务-详情查看/下载:仅对「任务-详情」类型生效,其余类型忽略 */
|
||
canAccessPrivate: boolean
|
||
}
|
||
|
||
export type PermissionPackMap = Record<string, MaterialPermissionFlags>
|
||
|
||
type TaskDetailOnlyFlagInput = {
|
||
canEditAssets?: boolean
|
||
canAccessPrivate?: boolean
|
||
}
|
||
|
||
/** 任务-详情以外的素材类型,不允许带出这两个组级特权位。 */
|
||
export function normalizeTaskDetailOnlyFlags(
|
||
materialType: string,
|
||
flags: TaskDetailOnlyFlagInput = {},
|
||
): Pick<MaterialPermissionFlags, 'canEditAssets' | 'canAccessPrivate'> {
|
||
const isTaskDetail = materialType === '任务-详情'
|
||
return {
|
||
canEditAssets: isTaskDetail && flags.canEditAssets === true,
|
||
canAccessPrivate: isTaskDetail && flags.canAccessPrivate === true,
|
||
}
|
||
}
|
||
|
||
/** 部门是底子,用户继承是加餐。同一动作只要有一边允许就算允许。 */
|
||
export function mergeDepartmentAndInheritPacks(
|
||
department: PermissionPackMap | null | undefined,
|
||
inherit: PermissionPackMap | null | undefined,
|
||
): PermissionPackMap {
|
||
const merged: PermissionPackMap = { ...(department || {}) }
|
||
if (!inherit) return merged
|
||
|
||
for (const materialType of Object.keys(inherit)) {
|
||
const extra = inherit[materialType]
|
||
const base = merged[materialType]
|
||
if (!base) {
|
||
merged[materialType] = { ...extra }
|
||
continue
|
||
}
|
||
merged[materialType] = {
|
||
canView: extra.canView || base.canView,
|
||
canDownload: extra.canDownload || base.canDownload,
|
||
canDownloadSource: extra.canDownloadSource || base.canDownloadSource,
|
||
canShare: extra.canShare || base.canShare,
|
||
canEditAssets: extra.canEditAssets || base.canEditAssets,
|
||
canAccessPrivate: extra.canAccessPrivate || base.canAccessPrivate,
|
||
}
|
||
}
|
||
return merged
|
||
}
|
||
|
||
/** 多组合并:允许的取并集,不允许的不会把别组已经给的拿掉。 */
|
||
export function unionPermissionPacks(
|
||
...packs: Array<PermissionPackMap | null | undefined>
|
||
): PermissionPackMap {
|
||
return packs.reduce<PermissionPackMap>(
|
||
(merged, pack) => mergeDepartmentAndInheritPacks(merged, pack),
|
||
{},
|
||
)
|
||
}
|
||
|
||
export type NearestGroupMemberships<T extends { departmentPath: string }> = {
|
||
attachedPath: string
|
||
items: T[]
|
||
}
|
||
|
||
/** 从自己往上找,最近一个进了权限组的部门。一个部门只进一组。 */
|
||
export function findNearestGroupMemberships<T extends { departmentPath: string }>(
|
||
path: string,
|
||
memberships: T[],
|
||
): NearestGroupMemberships<T> | null {
|
||
const chain = organizationPathChain(path)
|
||
if (chain.length === 0 || memberships.length === 0) return null
|
||
|
||
const byPath = new Map<string, T[]>()
|
||
for (const row of memberships) {
|
||
const key = normalizeOrganizationPath(row.departmentPath)
|
||
if (!key) continue
|
||
const list = byPath.get(key) || []
|
||
list.push(row)
|
||
byPath.set(key, list)
|
||
}
|
||
|
||
for (let index = chain.length - 1; index >= 0; index -= 1) {
|
||
const attachedPath = chain[index]
|
||
const items = byPath.get(attachedPath)
|
||
if (items && items.length > 0) {
|
||
return { attachedPath, items }
|
||
}
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
export type DepartmentGroupRef = {
|
||
departmentPath: string
|
||
slug: string
|
||
name: string
|
||
}
|
||
|
||
export type DepartmentGroupStatus = {
|
||
kind: 'own' | 'other' | 'inherit' | 'none'
|
||
groupName: string | null
|
||
groupSlug: string | null
|
||
inheritFromName: string | null
|
||
label: string
|
||
}
|
||
|
||
/** 分配部门时用的状态:已在本组 / 已在别组 / 随上级 / 未进组。 */
|
||
export function describeDepartmentGroupStatus(
|
||
path: string,
|
||
currentSlug: string | null | undefined,
|
||
memberships: DepartmentGroupRef[],
|
||
): DepartmentGroupStatus {
|
||
const nearest = findNearestGroupMemberships(path, memberships)
|
||
if (!nearest) {
|
||
return { kind: 'none', groupName: null, groupSlug: null, inheritFromName: null, label: '未进组' }
|
||
}
|
||
|
||
const group = nearest.items[0]
|
||
const groupName = group?.name || null
|
||
const groupSlug = group?.slug || null
|
||
const inheritFromName = lastOrganizationPathSegment(nearest.attachedPath) || nearest.attachedPath
|
||
const isOwnNode = nearest.attachedPath === normalizeOrganizationPath(path)
|
||
|
||
if (isOwnNode) {
|
||
if (currentSlug && groupSlug === currentSlug) {
|
||
return { kind: 'own', groupName, groupSlug, inheritFromName: null, label: '已在本组' }
|
||
}
|
||
return {
|
||
kind: 'other',
|
||
groupName,
|
||
groupSlug,
|
||
inheritFromName: null,
|
||
label: groupName ? `已在「${groupName}」` : '已在别的组',
|
||
}
|
||
}
|
||
|
||
return {
|
||
kind: 'inherit',
|
||
groupName,
|
||
groupSlug,
|
||
inheritFromName,
|
||
label: groupName ? `随 ${inheritFromName} · ${groupName}` : `随 ${inheritFromName}`,
|
||
}
|
||
}
|
||
|
||
export type OrganizationPermissionSource = 'own' | 'legacy' | 'inherit' | 'none'
|
||
|
||
export const LEGACY_DEPARTMENT_ALIASES: Record<string, string> = {
|
||
运营中心A: '运营中心',
|
||
运营中心B: '运营中心',
|
||
运营中心C: '运营中心',
|
||
运营中心E: '运营中心',
|
||
运营中心G: '运营中心',
|
||
卓禾运营: '运营中心',
|
||
朴美运营: '运营中心',
|
||
亿家达运营: '运营中心',
|
||
橡野里运营: '运营中心',
|
||
欧意朗运营: '运营中心',
|
||
索菲亚运营: '运营中心',
|
||
顾家运营: '运营中心',
|
||
品牌中心: '视觉中心',
|
||
策划中心: '视觉中心',
|
||
摄影中心: '视觉中心',
|
||
空间视觉部: '视觉中心',
|
||
品牌创意A部: '视觉中心',
|
||
品牌创意B部: '视觉中心',
|
||
品牌设计部: '视觉中心',
|
||
商品企划部: '视觉中心',
|
||
营销策划中心: '视觉中心',
|
||
业务支持: '业务支持-视觉支持',
|
||
}
|
||
|
||
export function organizationPathChain(path: string): string[] {
|
||
const parts = String(path || '')
|
||
.split('/')
|
||
.map((part) => part.trim())
|
||
.filter(Boolean)
|
||
|
||
const chain: string[] = []
|
||
let acc = ''
|
||
for (const part of parts) {
|
||
acc = acc ? `${acc}/${part}` : part
|
||
chain.push(acc)
|
||
}
|
||
return chain
|
||
}
|
||
|
||
export function lastOrganizationPathSegment(path: string): string {
|
||
const chain = organizationPathChain(path)
|
||
if (chain.length === 0) return ''
|
||
const last = chain[chain.length - 1]
|
||
const slash = last.lastIndexOf('/')
|
||
return slash >= 0 ? last.slice(slash + 1) : last
|
||
}
|
||
|
||
export function normalizeOrganizationPath(path: string | null | undefined): string {
|
||
return String(path || '')
|
||
.split('/')
|
||
.map((part) => part.trim())
|
||
.filter(Boolean)
|
||
.join('/')
|
||
}
|
||
|
||
/** 人是否在这个部门:本级,或挂在它下面的子部门。 */
|
||
export function isUnderOrganizationPath(
|
||
employeePath: string | null | undefined,
|
||
departmentPath: string | null | undefined,
|
||
): boolean {
|
||
const employee = normalizeOrganizationPath(employeePath)
|
||
const department = normalizeOrganizationPath(departmentPath)
|
||
if (!employee || !department) return false
|
||
return employee === department || employee.startsWith(`${department}/`)
|
||
}
|
||
|
||
export function collectEmployeeOrganizationPaths(
|
||
candidates: Array<string | null | undefined>,
|
||
): string[] {
|
||
const unique: string[] = []
|
||
const seen = new Set<string>()
|
||
for (const candidate of candidates) {
|
||
const path = normalizeOrganizationPath(candidate)
|
||
if (!path || seen.has(path)) continue
|
||
seen.add(path)
|
||
unique.push(path)
|
||
}
|
||
return collapseNestedOrganizationPaths(unique)
|
||
}
|
||
|
||
export type EmployeeOrganizationPathSource = {
|
||
departmentPath?: string | null
|
||
sourcePayload?: unknown
|
||
departmentMemberships?: Array<{
|
||
sourceDeptId?: string | number | null
|
||
departmentPath?: string | null
|
||
}>
|
||
}
|
||
|
||
export function collectEmployeeOrganizationSourceDeptIds(
|
||
profile: EmployeeOrganizationPathSource | null | undefined,
|
||
): string[] {
|
||
if (!profile) return []
|
||
const synced = ((profile.sourcePayload as { syncedFields?: Record<string, unknown> } | null)?.syncedFields ||
|
||
{}) as Record<string, unknown>
|
||
return Array.from(new Set([
|
||
...(profile.departmentMemberships || []).flatMap((membership) =>
|
||
parseOrganizationDepartmentIds(membership.sourceDeptId),
|
||
),
|
||
...parseOrganizationDepartmentIds(synced.departmentIds),
|
||
...parseOrganizationDepartmentIds(synced.employeeDeptIdList),
|
||
...parseOrganizationDepartmentIds(synced.registerDeptIds),
|
||
...parseOrganizationDepartmentIds(synced.flowerBookDeptId),
|
||
]))
|
||
}
|
||
|
||
/** 和数据口径同一套任职路径:主部门 + 兼任 + 花名册部门 ID。 */
|
||
export function resolveEmployeeOrganizationPaths(
|
||
profile: EmployeeOrganizationPathSource | null | undefined,
|
||
pathBySourceDeptId: Map<string, string> | Record<string, string>,
|
||
): string[] {
|
||
if (!profile) return []
|
||
const lookup =
|
||
pathBySourceDeptId instanceof Map
|
||
? pathBySourceDeptId
|
||
: new Map(Object.entries(pathBySourceDeptId))
|
||
const memberships = profile.departmentMemberships || []
|
||
const departmentIds = collectEmployeeOrganizationSourceDeptIds(profile)
|
||
// A known department identity is authoritative. Renamed/moved/retired
|
||
// departments must not regain access through stale roster path snapshots.
|
||
if (departmentIds.length > 0) {
|
||
const currentPaths = collectEmployeeOrganizationPaths(departmentIds.map((id) => lookup.get(id)))
|
||
const primary = normalizeOrganizationPath(profile.departmentPath)
|
||
return currentPaths.includes(primary)
|
||
? [primary, ...currentPaths.filter((path) => path !== primary)] : currentPaths
|
||
}
|
||
return collectEmployeeOrganizationPaths([
|
||
profile.departmentPath,
|
||
...memberships.map((membership) => membership.departmentPath),
|
||
])
|
||
}
|
||
|
||
/** 花名册常同时给上级和本组。只留最深的任职,避免「品牌创意A部 + 品牌中心」被当成两个职位。 */
|
||
export function collapseNestedOrganizationPaths(paths: string[]): string[] {
|
||
return paths.filter(
|
||
(path) => !paths.some((other) => other !== path && isUnderOrganizationPath(other, path)),
|
||
)
|
||
}
|
||
|
||
export function personBelongsToOrganizationPath(
|
||
employeePaths: Array<string | null | undefined>,
|
||
departmentPath: string | null | undefined,
|
||
): boolean {
|
||
return employeePaths.some((path) => isUnderOrganizationPath(path, departmentPath))
|
||
}
|
||
|
||
export function parseOrganizationDepartmentIds(value: unknown): string[] {
|
||
if (value == null) return []
|
||
if (Array.isArray(value)) {
|
||
return Array.from(new Set(value.flatMap((item) => parseOrganizationDepartmentIds(item))))
|
||
}
|
||
const text = String(value).trim()
|
||
if (!text) return []
|
||
if (text.startsWith('[')) {
|
||
try {
|
||
const parsed = JSON.parse(text)
|
||
if (Array.isArray(parsed)) return parseOrganizationDepartmentIds(parsed)
|
||
} catch {
|
||
// 花名册有时是半截 JSON,继续按分隔符拆。
|
||
}
|
||
}
|
||
return Array.from(
|
||
new Set(
|
||
text
|
||
.split(/[/>||,,;;\s\[\]]+/)
|
||
.map((item) => item.trim())
|
||
.filter((item) => /^\d+$/.test(item)),
|
||
),
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 每个部门路径上的人数(含子孙)。
|
||
* 一个人只加一次到每个祖先,禁止再按子节点滚加,否则公司节点会被加出三四倍。
|
||
*/
|
||
export function countPeopleOnOrganizationPaths(
|
||
employeePaths: Array<string | null | undefined>,
|
||
): Map<string, number> {
|
||
return countUniquePeopleOnOrganizationPaths(employeePaths.map((path) => [path]))
|
||
}
|
||
|
||
/**
|
||
* 一人多部门时:每个任职部门及其祖先都计他一次;
|
||
* 两个部门共用的上级(比如公司)仍然只计一个人。
|
||
*/
|
||
export function countUniquePeopleOnOrganizationPaths(
|
||
peoplePaths: Array<Array<string | null | undefined>>,
|
||
): Map<string, number> {
|
||
const counts = new Map<string, number>()
|
||
for (const paths of peoplePaths) {
|
||
const prefixes = new Set<string>()
|
||
for (const path of paths) {
|
||
for (const prefix of organizationPathChain(path || '')) {
|
||
prefixes.add(prefix)
|
||
}
|
||
}
|
||
Array.from(prefixes).forEach((prefix) => {
|
||
counts.set(prefix, (counts.get(prefix) ?? 0) + 1)
|
||
})
|
||
}
|
||
return counts
|
||
}
|
||
|
||
export function findOrganizationPermissionStorageKey(
|
||
path: string,
|
||
storedKeys: Iterable<string>,
|
||
): { key: string; source: Exclude<OrganizationPermissionSource, 'inherit' | 'none'> } | null {
|
||
const keys = storedKeys instanceof Set ? storedKeys : new Set(storedKeys)
|
||
if (keys.has(path)) {
|
||
return { key: path, source: 'own' }
|
||
}
|
||
|
||
const name = lastOrganizationPathSegment(path)
|
||
const alias = LEGACY_DEPARTMENT_ALIASES[name]
|
||
if (alias && keys.has(alias)) {
|
||
return { key: alias, source: 'legacy' }
|
||
}
|
||
|
||
if (name && keys.has(name)) {
|
||
return { key: name, source: 'legacy' }
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
export function resolveOrganizationPermissionKey(
|
||
path: string,
|
||
storedKeys: Iterable<string>,
|
||
): { key: string; source: OrganizationPermissionSource; inheritFrom: string | null } | null {
|
||
const chain = organizationPathChain(path)
|
||
if (chain.length === 0) return null
|
||
|
||
const keys = storedKeys instanceof Set ? storedKeys : new Set(storedKeys)
|
||
|
||
for (let index = chain.length - 1; index >= 0; index -= 1) {
|
||
const candidate = chain[index]
|
||
const matched = findOrganizationPermissionStorageKey(candidate, keys)
|
||
if (!matched) continue
|
||
|
||
const isOwnNode = candidate === path
|
||
return {
|
||
key: matched.key,
|
||
source: isOwnNode ? matched.source : 'inherit',
|
||
inheritFrom: isOwnNode ? null : candidate,
|
||
}
|
||
}
|
||
|
||
return { key: path, source: 'none', inheritFrom: null }
|
||
}
|
||
|
||
export type OrganizationTreeNode<T extends { path: string; children: T[] }> = T
|
||
|
||
export function collectOccupiedOrganizationPaths(paths: Array<string | null | undefined>): Set<string> {
|
||
const occupied = new Set<string>()
|
||
for (const path of paths) {
|
||
for (const prefix of organizationPathChain(path || '')) {
|
||
occupied.add(prefix)
|
||
}
|
||
}
|
||
return occupied
|
||
}
|
||
|
||
export function pruneOrganizationTree<T extends { path: string; children: T[] }>(
|
||
nodes: T[],
|
||
occupiedPaths: Iterable<string>,
|
||
): T[] {
|
||
const occupied = occupiedPaths instanceof Set ? occupiedPaths : new Set(occupiedPaths)
|
||
return nodes.flatMap((node) => {
|
||
const children = pruneOrganizationTree(node.children, occupied)
|
||
if (children.length === 0 && !occupied.has(node.path)) return []
|
||
return [{ ...node, children }]
|
||
})
|
||
}
|