export type MaterialPermissionFlags = { canView: boolean canDownload: boolean canDownloadSource: boolean canShare: boolean /** 任务-详情单图编辑(替换/改名/删除):仅对「任务-详情」类型生效,其余类型忽略 */ canEditAssets: boolean } export type PermissionPackMap = Record /** 部门是底子,用户继承是加餐。同一动作只要有一边允许就算允许。 */ 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, } } return merged } /** 多组合并:允许的取并集,不允许的不会把别组已经给的拿掉。 */ export function unionPermissionPacks( ...packs: Array ): PermissionPackMap { return packs.reduce( (merged, pack) => mergeDepartmentAndInheritPacks(merged, pack), {}, ) } export type NearestGroupMemberships = { attachedPath: string items: T[] } /** 从自己往上找,最近一个进了权限组的部门。一个部门只进一组。 */ export function findNearestGroupMemberships( path: string, memberships: T[], ): NearestGroupMemberships | null { const chain = organizationPathChain(path) if (chain.length === 0 || memberships.length === 0) return null const byPath = new Map() 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 = { 运营中心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[] { const unique: string[] = [] const seen = new Set() 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 } | null)?.syncedFields || {}) as Record 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 | Record, ): string[] { if (!profile) return [] const lookup = pathBySourceDeptId instanceof Map ? pathBySourceDeptId : new Map(Object.entries(pathBySourceDeptId)) const memberships = profile.departmentMemberships || [] return collectEmployeeOrganizationPaths([ profile.departmentPath, ...memberships.map((membership) => membership.departmentPath), ...collectEmployeeOrganizationSourceDeptIds(profile).map((id) => lookup.get(id)), ]) } /** 花名册常同时给上级和本组。只留最深的任职,避免「品牌创意A部 + 品牌中心」被当成两个职位。 */ export function collapseNestedOrganizationPaths(paths: string[]): string[] { return paths.filter( (path) => !paths.some((other) => other !== path && isUnderOrganizationPath(other, path)), ) } export function personBelongsToOrganizationPath( employeePaths: Array, 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, ): Map { return countUniquePeopleOnOrganizationPaths(employeePaths.map((path) => [path])) } /** * 一人多部门时:每个任职部门及其祖先都计他一次; * 两个部门共用的上级(比如公司)仍然只计一个人。 */ export function countUniquePeopleOnOrganizationPaths( peoplePaths: Array>, ): Map { const counts = new Map() for (const paths of peoplePaths) { const prefixes = new Set() 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, ): { key: string; source: Exclude } | 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, ): { 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 export function collectOccupiedOrganizationPaths(paths: Array): Set { const occupied = new Set() for (const path of paths) { for (const prefix of organizationPathChain(path || '')) { occupied.add(prefix) } } return occupied } export function pruneOrganizationTree( nodes: T[], occupiedPaths: Iterable, ): 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 }] }) }