feat(gerp-ui): 批量职务调整树形选择 + OA 审批页 + 虚拟职务一键绑定

- BatchPositionDialog:部门列表页以部门树组织员工、工作组列表页以工作组树组织成员,
  均可勾选具体的人;选中行成员自动预勾选,可追加/取消;提交映射 UserRole/UserRoleWorkgroup
- Department/Workgroup 列表页 toolbar 新增"批量调整职务"入口(CompanyUUID 兜底当前用户公司)
- CrudTable 新增 #toolbar 插槽(暴露 selectedRows/refresh)
- OA 审批页 Approval.vue(请假/出差待审批,30s 轮询,通过/驳回)+ api/oa.ts
- AppConfigPage 虚拟职务"一键绑定"按钮 + api/app.ts autoBindVirtualPositions
- 附带:OA/财务/eHRM 应用页、MultiEntityPicker、菜单/布局优化等系列改动
This commit is contained in:
TermiNexus committed 2026-08-30 13:23:49 +08:00
1 parent 93464d0c3c
commit 3d4c46b7ee
50 files changed
+4969 -621

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GERP 管理系统</title>
</head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated

Before

Width:  |  Height:  |  Size: 9.3 KiB

+117
View File
@@ -24,6 +24,10 @@ export interface AppItem {
InstalledCompanies?: string[]
UserEnabled?: number
ApplyStatus?: number
/** 严格职务可见开关:1=无职务绑定时全员不可见(必须绑定职务);0=宽松(无绑定全员可见) */
StrictPositionVisible?: number
/** 强制职务绑定(应用清单声明,硬性):1=无绑定时必不可见,不受管理员开关影响 */
RequirePositionBinding?: number
/** 远端注入(MSYNC):来源/提供方服务/在线状态 */
Source?: string
ServiceName?: string
@@ -68,6 +72,64 @@ export interface AppConfigItem {
Updated: boolean
}
/** 应用虚拟职务(含已绑定真实职务) */
export interface VirtualMappingItem {
AppVirtualPositionUUID: string
Code: string
Name: string
MenuNames: string[]
PositionUUIDs: string[]
/** 与 PositionUUIDs 对齐的职务名(回显 tag 用) */
PositionNames?: string[]
}
/** 真实职务选项(配置页选择用) */
export interface PositionOption {
PositionUUID: string
PositionName: string
}
/** 通用部门与职务模板同步——缺失项(按名称匹配,同名已有=复用) */
export interface PositionTemplateDiffItem {
CategoryName: string
Exists: boolean
Status: 'new' | 'exists'
Positions: {
PositionName: string
Exists: boolean
Status: 'new' | 'exists'
}[]
}
/** 通用部门与职务同步结果统计 */
export interface PositionTemplateSyncStats {
CreatedCategories: string[]
CreatedPositions: string[]
ReusedCategories: number
ReusedPositions: number
}
/** 通用部门与职务反向移除——可移除性预览项 */
export interface PositionTemplateRemoveItem {
CategoryName: string
Exists: boolean
Removable: boolean
BlockedReason: string
Positions: {
PositionName: string
Exists: boolean
Removable: boolean
BlockedReason: string
}[]
}
/** 通用部门与职务移除结果统计 */
export interface PositionTemplateRemoveStats {
RemovedCategories: string[]
RemovedPositions: string[]
BlockedPositions: string[]
}
/** 应用/功能 API 客户端 */
export const appApi = {
/** 应用市场列表(附租户/公司安装/个人启用/申请状态) */
@@ -109,6 +171,61 @@ export const appApi = {
/** 租户管理员:待审批申请列表 */
pending: (): Promise<{ list: PendingItem[]; total: number }> => get<{ list: PendingItem[]; total: number }>('/API/App/Pending'),
/** 管理员:设置应用严格职务可见开关(1=严格,必须绑定职务才可见;0=宽松) */
setStrictVisible: (AppUUID: string, StrictPositionVisible: number): Promise<{ msg: string }> =>
post<{ msg: string }>('/API/App/SetStrictVisible', { AppUUID, StrictPositionVisible }),
/** 管理员:应用虚拟职务↔真实职务映射(配置页读取;CompanyUUID 指定公司范围,空=当前公司) */
getVirtualMapping: (AppUUID: string, CompanyUUID?: string): Promise<{ VirtualPositions: VirtualMappingItem[]; AllPositions: PositionOption[] }> =>
get<{ VirtualPositions: VirtualMappingItem[]; AllPositions: PositionOption[] }>(
'/API/App/VirtualMapping',
CompanyUUID ? { AppUUID, CompanyUUID } : { AppUUID },
),
/** 管理员:重写虚拟职务的真实职务绑定(配置页保存;CompanyUUID 指定公司范围,空=当前公司) */
setVirtualMapping: (
AppUUID: string,
Mappings: { AppVirtualPositionUUID: string; PositionUUIDs: string[] }[],
CompanyUUID?: string,
): Promise<{ msg: string }> =>
post<{ msg: string }>('/API/App/VirtualMapping', {
AppUUID,
Mappings,
...(CompanyUUID ? { CompanyUUID } : {}),
}),
/** 管理员:一键绑定虚拟职务↔真实职务(按名称自动匹配;前提:职务模板已同步) */
autoBindVirtualPositions: (
AppUUID: string,
CompanyUUID?: string,
): Promise<{ msg: string; Bound: string[]; Unmatched: string[] }> =>
post<{ msg: string; Bound: string[]; Unmatched: string[] }>('/API/App/VirtualAutoBind', {
AppUUID,
...(CompanyUUID ? { CompanyUUID } : {}),
}),
/** 管理员:应用通用部门与职务模板缺失项(配置页同步前预览) */
getPositionTemplateDiff: (AppUUID: string): Promise<{ Categories: PositionTemplateDiffItem[]; NeedSync: boolean }> =>
get<{ Categories: PositionTemplateDiffItem[]; NeedSync: boolean }>(
'/API/App/PositionTemplateDiff',
{ AppUUID },
),
/** 管理员:同步应用声明的通用部门与职务到当前租户(按名称,已有复用) */
syncPositionTemplates: (AppUUID: string): Promise<{ msg: string } & PositionTemplateSyncStats> =>
post<{ msg: string } & PositionTemplateSyncStats>('/API/App/PositionTemplateSync', { AppUUID }),
/** 管理员:应用通用部门与职务可移除项预览(员工均未持有才可移除) */
getPositionTemplateRemovePreview: (AppUUID: string): Promise<{ Categories: PositionTemplateRemoveItem[]; AnyRemovable: boolean }> =>
get<{ Categories: PositionTemplateRemoveItem[]; AnyRemovable: boolean }>(
'/API/App/PositionTemplateRemovePreview',
{ AppUUID },
),
/** 管理员:移除应用声明的通用部门与职务(校验员工持有后删除) */
removePositionTemplates: (AppUUID: string): Promise<{ msg: string } & PositionTemplateRemoveStats> =>
post<{ msg: string } & PositionTemplateRemoveStats>('/API/App/PositionTemplateRemove', { AppUUID }),
/** 应用配置:读取(GET,CompanyUUID 可选——空=租户默认配置)/保存(POST,可指定公司) */
config: (AppUUID: string, CompanyUUID?: string): Promise<{ Configs: AppConfigItem[]; Total: number; Scope: string; CompanyUUID: string }> =>
get<{ Configs: AppConfigItem[]; Total: number; Scope: string; CompanyUUID: string }>(
+23 -3
View File
@@ -1,4 +1,4 @@
import { get, post } from './request'
import { get, post, upload } from './request'
import type { UserInfo } from '@/types/api'
/** 登录参数(对齐后端 LoginSerializer 字段:UserName / UserPassword) */
@@ -40,6 +40,8 @@ export const refreshToken = (): Promise<{ AccessToken: string }> => {
export interface RoleItem {
RoleUUID?: string
RoleName: string
/** 角色原名(Role.RoleName):覆盖名 RoleName 可能被 UserRole 定制,原名供对照展示 */
RoleNameOriginal?: string
RoleStatus?: string | number
AssignScope?: string
RoleDesc?: string
@@ -72,5 +74,23 @@ export const getCurrentUser = (silent = false): Promise<UserInfo> => {
return get<UserInfo>('/Query/GetELTUserInfo', undefined, silent ? { silent: true } : undefined)
}
/** 获取用户头像 URL(直接作为图片 src 使用) */
export const getAvatarUrl = (): string => '/Query/GetELTUserAvatar'
/** 获取用户头像 URL(直接作为图片 src 使用;version 时间戳用于上传后强制刷新缓存) */
export const getAvatarUrl = (version = 0): string =>
version ? `/Query/GetELTUserAvatar?t=${version}` : '/Query/GetELTUserAvatar'
/** 上传头像(multipart,字段 avatar;后端 /Modify/ModifyELTUserAvatar) */
export const uploadAvatar = (file: File): Promise<void> => {
const formData = new FormData()
formData.append('avatar', file)
return upload<void>('/Modify/ModifyELTUserAvatar', formData)
}
/** 修改个人配置(名字/邮箱/电话/性别/签名等,后端 /API/User/ModifyPersonalConfig) */
export const modifyPersonalConfig = (data: Record<string, unknown>): Promise<{ reload?: boolean }> => {
return post<{ reload?: boolean }>('/API/User/ModifyPersonalConfig', data)
}
/** 修改密码(旧密码 + 新密码,后端 /API/User/ModifyPassword) */
export const modifyPassword = (data: { UserOldPassword: string; UserNewPassword: string }): Promise<void> => {
return post<void>('/API/User/ModifyPassword', data)
}
+15
View File
@@ -75,4 +75,19 @@ export const formApi = {
search: <T = Record<string, unknown>>(entity: string, keyword: string): Promise<T[]> => {
return get<T[]>(`/API/FormAPI/${entity}Search`, { keyword })
},
/**
* 批量调整员工职务(多员工 × 多职务 批量增加/移除)
* 后端 /API/FormAPI/BatchPositions,职务统一落 UserRoleDepartmentPosition 子表
*/
batchPositions: <T = Record<string, unknown>>(data: {
Table: 'UserRole' | 'UserRoleDepartment' | 'UserRoleWorkgroup' | 'Department' | 'Workgroup'
Mode: 'add' | 'remove'
PKs: string[]
PositionUUIDs: string[]
/** 公司上下文:后端按该公司解析主部门关联(超管跨租户也限定) */
CompanyUUID?: string
}): Promise<T> => {
return post<T>('/API/FormAPI/BatchPositions', data)
},
}
+31
View File
@@ -0,0 +1,31 @@
import { get, post } from './request'
/** OA 待审批记录(请假/出差) */
export interface OaApprovalItem {
UUID: string
UserUUID: string
UserName: string
StartTime: string
EndTime: string
Days: string
Reason: string
Remark: string
CreateTime: string
}
/** OA 待审批列表响应 */
export interface OaPendingApprovals {
leave: OaApprovalItem[]
travel: OaApprovalItem[]
}
/** OA 审批 API:当前用户作为审批人的待审批列表与审批操作 */
export const oaApi = {
/** 待审批列表(Type: all | leave | travel) */
pendingApprovals: (Type: 'all' | 'leave' | 'travel' = 'all'): Promise<OaPendingApprovals> =>
get<OaPendingApprovals>('/API/Oa/PendingApprovals', { Type }),
/** 审批操作:通过 / 驳回(仅审批人本人) */
approve: (Type: 'leave' | 'travel', UUID: string, Approved: boolean, Remark = ''): Promise<{ msg: string }> =>
post<{ msg: string }>('/API/Oa/Approve', { Type, UUID, Approved, Remark }),
}
+32
View File
@@ -90,6 +90,38 @@ const builtin: Record<string, AppFrontendModule> = {
FrontendType: 'legacy',
LegacySrc: 'pages/private/CreateE/enterprise.html',
},
'dingcalc-finance': {
AppCode: 'dingcalc-finance',
Title: '鼎算财务',
Icon: 'Money',
FrontendRoute: '/finance/voucher',
FrontendType: 'vue',
LegacySrc: '',
},
'product-order': {
AppCode: 'product-order',
Title: '产品与订单',
Icon: 'Goods',
FrontendRoute: '/inventory/products',
FrontendType: 'vue',
LegacySrc: '',
},
ehrm: {
AppCode: 'ehrm',
Title: '人力资源管理扩展',
Icon: 'UserFilled',
FrontendRoute: '/ehrm/profile',
FrontendType: 'vue',
LegacySrc: '',
},
oa: {
AppCode: 'oa',
Title: '办公自动化流程',
Icon: 'Files',
FrontendRoute: '/oa/leave',
FrontendType: 'vue',
LegacySrc: '',
},
}
/** 远端注册表(MSYNC 上报的应用,本地内置同名优先) */
+477
View File
@@ -0,0 +1,477 @@
<script setup lang="ts">
/**
* BatchPositionDialog 批量调整职务弹窗
*
* 三个入口(部门详情 / 工作组详情 / 公司详情-员工)共用的批量职务操作:
* - 多选职务(tree 视图:通用部门树 → 职务)
* - 多选员工(tree 视图:公司页=部门树分组员工 UserRoleSearch;部门/工作组页=各自成员树)
* - 批量增加 / 批量删除职务(后端 FormService.batch_positions,统一落
* UserRoleDepartmentPosition 子表,工作组/公司页解析到成员的"主部门关联")
*
* 两种交互形态:
* - 有 preselected(表格已选中行):只展示职务树,应用到选中行
* - 无 preselected:员工树 + 职务树都展示,先选员工再选职务
*/
import { ref, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { formApi } from '@/api/form'
import { get } from '@/api/request'
/** 批量目标表 */
type BatchTargetTable =
| 'UserRole'
| 'UserRoleDepartment'
| 'UserRoleWorkgroup'
| 'Department'
| 'Workgroup'
/** 已选中行(表格复选传入) */
interface PreselectedRow {
pk: string
label: string
}
/** 树节点视图模型 */
interface TreeNode {
id: string
label: string
children?: TreeNode[]
}
const props = withDefaults(defineProps<{
/** 批量目标表:UserRole(公司页)/ UserRoleDepartment(部门页)/ UserRoleWorkgroup(工作组页) */
table: BatchTargetTable
/** 上下文过滤(DeptUUID / WorkgroupUUID / CompanyUUID) */
context?: { DeptUUID?: string; WorkgroupUUID?: string; CompanyUUID?: string }
/** 表格已选中行(有值时仅选职务,应用到这些行;空则走员工树选择) */
preselected?: PreselectedRow[]
/** 弹窗标题 */
title?: string
}>(), {
context: () => ({}),
preselected: () => [],
title: '',
})
const emit = defineEmits<{
(e: 'success'): void
}>()
const visible = defineModel<boolean>('visible', { default: false })
const loading = ref(false)
const employeeTree = ref<TreeNode[]>([])
const positionTree = ref<TreeNode[]>([])
/** 员工/目标树组件实例(树形目标模式读取勾选状态、预勾选外部选中行) */
const employeeTreeRef = ref()
/** 已选员工叶子 PK(无 preselected 时从员工树勾选) */
const selectedEmployeePks = ref<Set<string>>(new Set())
/** 已选职务叶子 PK */
const selectedPositionPks = ref<Set<string>>(new Set())
// 分组节点 id 集合(提交时过滤掉,只提交叶子)
const employeeGroupIds = ref<Set<string>>(new Set())
const positionGroupIds = ref<Set<string>>(new Set())
const dialogTitle = computed(() => props.title || '批量调整职务')
/** 树形目标模式:部门/工作组列表页目标区为"实体树 + 成员叶子"(勾选实体节点级联选其成员,
* 也可单独勾选具体的人);提交时映射到成员叶子表 */
const targetIsTree = computed(() => props.table === 'Department' || props.table === 'Workgroup')
/** 树形目标模式提交的成员表:部门页=UserRole(部门树+员工叶子),工作组页=UserRoleWorkgroup(工作组树+成员叶子) */
const targetLeafTable = computed<BatchTargetTable>(() =>
props.table === 'Workgroup' ? 'UserRoleWorkgroup' : 'UserRole',
)
/** 目标区标题 */
const employeeTitle = computed(() => {
if (props.table === 'UserRoleDepartment') return '本部门员工(勾选左侧复选框选择)'
if (props.table === 'UserRoleWorkgroup') return '本工作组成员(勾选左侧复选框选择)'
if (props.table === 'Workgroup') return `成员(${props.preselected.length ? employeeHint.value : '按工作组组织,勾选具体的人'})`
if (props.table === 'Department') return `员工(${props.preselected.length ? employeeHint.value : '按部门组织,勾选具体的人'})`
return `员工(${props.preselected.length ? employeeHint.value : '勾选左侧复选框选择'})`
})
/** 目标区描述:部门/工作组列表页选中时提示已预勾选其成员,可追加具体的人 */
const employeeHint = computed(() => {
const n = props.preselected.length
if (props.table === 'Department') return `已选 ${n} 个部门,成员已预勾选,可追加具体员工`
if (props.table === 'Workgroup') return `已选 ${n} 个工作组,成员已预勾选,可追加具体成员`
return `已选 ${n} 人`
})
/** 组装树:分组节点(树)+ 叶子按 groupKey 归组,未归组进"未分组"根 */
function buildTree(
nodes: Array<{ uuid: string; name: string; parentUuid?: string }>,
leaves: Array<{ groupKey?: string; id: string; label: string }>,
): { tree: TreeNode[]; groupIds: Set<string> } {
const map = new Map<string, TreeNode>()
for (const n of nodes) {
if (!n.uuid) continue
map.set(n.uuid, { id: n.uuid, label: n.name || n.uuid, children: [] })
}
const roots: TreeNode[] = []
for (const n of nodes) {
const node = map.get(n.uuid)
if (!node) continue
if (n.parentUuid && map.has(n.parentUuid)) {
map.get(n.parentUuid)!.children!.push(node)
} else {
roots.push(node)
}
}
const groupIds = new Set<string>(map.keys())
const ungrouped: TreeNode[] = []
for (const l of leaves) {
const leaf: TreeNode = { id: l.id, label: l.label }
if (l.groupKey && map.has(l.groupKey)) {
map.get(l.groupKey)!.children!.push(leaf)
} else {
ungrouped.push(leaf)
}
}
if (ungrouped.length) roots.push({ id: '__ungrouped__', label: '未分组', children: ungrouped })
return { tree: roots, groupIds }
}
/** 响应拦截器已解包 {code,data}:兼容纯数组与 {list, tree, has_more} */
async function fetchTreeData(
api: string,
params: Record<string, unknown>,
): Promise<{ list: unknown[]; tree: unknown[]; hasMore: boolean }> {
const res = await get<unknown>(api, params)
if (Array.isArray(res)) return { list: res, tree: [], hasMore: false }
const obj = (res ?? {}) as Record<string, unknown>
return {
list: Array.isArray(obj.list) ? (obj.list as unknown[]) : [],
tree: Array.isArray(obj.tree) ? (obj.tree as unknown[]) : [],
hasMore: typeof obj.has_more === 'boolean' ? obj.has_more : false,
}
}
/** 分页拉全(UserRoleSearch 等按 offset+has_more 翻页;安全上限防失控) */
const PAGE_SIZE = 20
const MAX_FETCH = 500
/** 加载员工树(无 preselected 时) */
async function loadEmployeeTree(): Promise<void> {
const ctx = props.context ?? {}
selectedEmployeePks.value = new Set()
if (props.table === 'UserRole') {
// 公司页:UserRoleSearch 部门树 + 员工(user-role 对)按部门分组(翻页拉全)
let offset = 0
let tree: unknown[] = []
const allList: unknown[] = []
let hasMore = true
while (hasMore) {
const params: Record<string, unknown> = { keyword: '', tree: '1', offset }
if (ctx.CompanyUUID) params.CompanyUUID = ctx.CompanyUUID
const r = await fetchTreeData('/API/FormAPI/UserRoleSearch', params)
if (!tree.length) tree = r.tree
allList.push(...r.list)
hasMore = r.hasMore
offset += PAGE_SIZE
if (allList.length >= MAX_FETCH) hasMore = false
}
const leaves = allList
.map((r) => r as Record<string, unknown>)
.map((r) => ({
groupKey: r.DeptUUID ? String(r.DeptUUID) : '',
id: String(r.UserRoleUUID ?? ''),
// 角色覆盖名优先,空则角色真名(对齐员工列表约定)
label: `${String((r.RoleNameCover || r.RoleName) ?? '')}${r.UserName ? `(${String(r.UserName)})` : ''}` || String(r.UserRoleUUID ?? ''),
}))
.filter((l) => l.id)
const built = buildTree(tree as Array<{ uuid: string; name: string; parentUuid?: string }>, leaves)
employeeTree.value = built.tree
employeeGroupIds.value = built.groupIds
} else if (props.table === 'UserRoleDepartment') {
// 部门页:当前部门成员(UserRoleDepartment 行)
const params: Record<string, unknown> = { DeptUUID: ctx.DeptUUID || '', CompanyUUID: ctx.CompanyUUID || '' }
const res = await get<unknown>('/API/FormAPI/Get/UserRoleDepartment', params)
const list = Array.isArray(res) ? (res as unknown[]) : (res as Record<string, unknown>)?.list || []
const leaves = (list as Record<string, unknown>[])
.map((r) => ({
groupKey: '',
id: String(r.UserRoleDepartmentUUID ?? ''),
// 角色覆盖名优先,空则角色真名(对齐员工列表约定)
label: `${String((r.RoleNameCover || r.UserRoleUUID_label) ?? '')}${r.UserUUID_label ? `(${String(r.UserUUID_label)})` : ''}` || String(r.UserRoleDepartmentUUID ?? ''),
}))
.filter((l) => l.id)
const built = buildTree([], leaves)
employeeTree.value = [{ id: '__dept__', label: '本部门员工', children: built.tree }]
employeeGroupIds.value = new Set(['__dept__', ...built.groupIds])
} else if (props.table === 'Department') {
// 部门列表页:以部门为树组织员工(UserRoleSearch 部门树分组,公司键取部门所属公司)
let offset = 0
let tree: unknown[] = []
const allList: unknown[] = []
let hasMore = true
while (hasMore) {
const params: Record<string, unknown> = { keyword: '', tree: '1', offset }
if (ctx.CompanyUUID) params.CompanyUUID = ctx.CompanyUUID
const r = await fetchTreeData('/API/FormAPI/UserRoleSearch', params)
if (!tree.length) tree = r.tree
allList.push(...r.list)
hasMore = r.hasMore
offset += PAGE_SIZE
if (allList.length >= MAX_FETCH) hasMore = false
}
const leaves = allList
.map((r) => r as Record<string, unknown>)
.map((r) => ({
groupKey: r.DeptUUID ? String(r.DeptUUID) : '',
id: String(r.UserRoleUUID ?? ''),
// 角色覆盖名优先,空则角色真名(对齐员工列表约定)
label: `${String((r.RoleNameCover || r.RoleName) ?? '')}${r.UserName ? `(${String(r.UserName)})` : ''}` || String(r.UserRoleUUID ?? ''),
}))
.filter((l) => l.id)
const built = buildTree(tree as Array<{ uuid: string; name: string; parentUuid?: string }>, leaves)
employeeTree.value = built.tree
employeeGroupIds.value = built.groupIds
} else if (props.table === 'Workgroup') {
// 工作组列表页:以工作组为树组织成员(工作组节点 + 其成员叶子,公司键取工作组所属公司)
// 1) 拉全公司工作组(WorkgroupSearch 分页拉全 + 强制公司过滤)
const wgMap = new Map<string, string>()
let offset = 0
let hasMore = true
while (hasMore) {
const params: Record<string, unknown> = { keyword: '', offset }
if (ctx.CompanyUUID) params.CompanyUUID = ctx.CompanyUUID
const r = await fetchTreeData('/API/FormAPI/WorkgroupSearch', params)
for (const w of r.list as Record<string, unknown>[]) {
const uuid = String(w.WorkgroupUUID ?? '')
if (uuid) wgMap.set(uuid, String(w.WorkgroupName ?? uuid))
}
hasMore = r.hasMore
offset += PAGE_SIZE
if (wgMap.size >= MAX_FETCH) hasMore = false
}
// 2) 拉全公司工作组成员(UserRoleWorkgroup 行,精确字段过滤按公司)
const params: Record<string, unknown> = { CompanyUUID: ctx.CompanyUUID || '' }
const res = await get<unknown>('/API/FormAPI/Get/UserRoleWorkgroup', params)
const list = Array.isArray(res) ? (res as unknown[]) : (res as Record<string, unknown>)?.list || []
// 3) 组装:工作组节点 + 成员叶子(仅保留属于本公司工作组的成员)
const nodes = [...wgMap.entries()].map(([uuid, name]) => ({ uuid, name }))
const leaves = (list as Record<string, unknown>[])
.map((r) => ({
groupKey: r.WorkgroupUUID ? String(r.WorkgroupUUID) : '',
id: String(r.UserRoleWorkgroupUUID ?? ''),
// 角色覆盖名优先,空则角色真名(对齐成员列表约定)
label: `${String((r.RoleNameCover || r.UserRoleUUID_label) ?? '')}${r.UserUUID_label ? `(${String(r.UserUUID_label)})` : ''}` || String(r.UserRoleWorkgroupUUID ?? ''),
}))
.filter((l) => l.id && l.groupKey && wgMap.has(l.groupKey))
const built = buildTree(nodes, leaves)
employeeTree.value = built.tree
employeeGroupIds.value = built.groupIds
} else {
// 工作组页:当前工作组成员(UserRoleWorkgroup 行)
const params: Record<string, unknown> = { WorkgroupUUID: ctx.WorkgroupUUID || '', CompanyUUID: ctx.CompanyUUID || '' }
const res = await get<unknown>('/API/FormAPI/Get/UserRoleWorkgroup', params)
const list = Array.isArray(res) ? (res as unknown[]) : (res as Record<string, unknown>)?.list || []
const leaves = (list as Record<string, unknown>[])
.map((r) => ({
groupKey: '',
id: String(r.UserRoleWorkgroupUUID ?? ''),
// 角色覆盖名优先,空则角色真名(对齐员工列表约定)
label: `${String((r.RoleNameCover || r.UserRoleUUID_label) ?? '')}${r.UserUUID_label ? `(${String(r.UserUUID_label)})` : ''}` || String(r.UserRoleWorkgroupUUID ?? ''),
}))
.filter((l) => l.id)
const built = buildTree([], leaves)
employeeTree.value = [{ id: '__workgroup__', label: '本工作组成员', children: built.tree }]
employeeGroupIds.value = new Set(['__workgroup__', ...built.groupIds])
}
}
/** 加载职务树(通用部门树 → 职务,按当前公司所属租户过滤) */
async function loadPositionTree(): Promise<void> {
selectedPositionPks.value = new Set()
const params: Record<string, unknown> = { keyword: '', tree: '1' }
// 职务为租户级目录:带 CompanyUUID 让后端反查公司所属租户过滤(超管跨租户也仅显示本公司职务)
if (props.context?.CompanyUUID) params.CompanyUUID = props.context.CompanyUUID
const { list, tree } = await fetchTreeData('/API/FormAPI/PositionSearch', params)
const leaves = (list as Record<string, unknown>[])
.map((r) => ({
groupKey: r.CategoryUUID ? String(r.CategoryUUID) : '',
id: String(r.PositionUUID ?? ''),
label: String(r.PositionName ?? '') || String(r.PositionUUID ?? ''),
}))
.filter((l) => l.id)
const built = buildTree(tree as Array<{ uuid: string; name: string; parentUuid?: string }>, leaves)
positionTree.value = built.tree
positionGroupIds.value = built.groupIds
}
/** el-tree check 回调:只收集叶子节点 PK(TreeKey = string | number,统一转字符串),
* 部门/工作组分组节点不提交(其下员工叶子级联勾选) */
function onEmployeeCheck(_data: unknown, payload: { checkedKeys: Array<string | number> }): void {
selectedEmployeePks.value = new Set(
payload.checkedKeys.map(String).filter((k) => !employeeGroupIds.value.has(k)),
)
}
function onPositionCheck(_data: unknown, payload: { checkedKeys: Array<string | number> }): void {
selectedPositionPks.value = new Set(
payload.checkedKeys.map(String).filter((k) => !positionGroupIds.value.has(k)),
)
}
/** 提交批量操作 */
async function submit(mode: 'add' | 'remove'): Promise<void> {
let pks: string[]
let submitTable: BatchTargetTable = props.table
if (targetIsTree.value) {
// 部门/工作组列表页:目标为树下的成员叶子(部门=UserRole / 工作组=UserRoleWorkgroup),
// 提交走对应成员表分支;读树当前勾选状态(含预勾选成员与用户增删,过滤实体分组节点)
submitTable = targetLeafTable.value
pks = (employeeTreeRef.value?.getCheckedKeys() ?? [])
.map(String)
.filter((k: string) => !employeeGroupIds.value.has(k))
} else {
pks = props.preselected.length
? props.preselected.map((p) => p.pk)
: [...selectedEmployeePks.value]
}
const positions = [...selectedPositionPks.value]
if (!pks.length) {
ElMessage.warning('请先选择员工')
return
}
if (!positions.length) {
ElMessage.warning('请先选择职务')
return
}
loading.value = true
try {
await formApi.batchPositions({
Table: submitTable,
Mode: mode,
PKs: pks,
PositionUUIDs: positions,
// 公司上下文:后端按该公司解析主部门关联(超管跨租户也限定,避免挂到其它公司)
CompanyUUID: props.context?.CompanyUUID || '',
})
ElMessage.success(mode === 'add' ? '批量新增职务成功' : '批量删除职务成功')
emit('success')
visible.value = false
} catch {
// 错误提示由拦截器统一处理
} finally {
loading.value = false
}
}
// 打开弹窗时加载数据(职务树一次加载;员工树:树形目标模式始终加载并预勾选外部选中行,
// 其它模式仅在无 preselected 时加载)
watch(visible, (v) => {
if (!v) return
void loadPositionTree()
if (targetIsTree.value) {
void loadEmployeeTree().then(() => {
const keys = props.preselected.map((p) => p.pk).filter(Boolean)
if (!keys.length) return
// 等树渲染完成再预勾选(级联模式下勾选父节点连带子节点,均为批量目标)
void nextTick(() => {
employeeTreeRef.value?.setCheckedKeys(keys)
onEmployeeCheck(null, { checkedKeys: employeeTreeRef.value?.getCheckedKeys() ?? [] })
})
})
} else if (!props.preselected.length) {
void loadEmployeeTree()
}
})
</script>
<template>
<el-dialog v-model="visible" :title="dialogTitle" width="780px" destroy-on-close>
<div class="batch-pos-grid">
<!-- 目标/员工选择区 -->
<div class="batch-pos-panel">
<div class="batch-pos-panel-title">{{ employeeTitle }}</div>
<template v-if="props.preselected.length && !targetIsTree">
<div class="batch-pos-preselected">
<el-tag v-for="p in props.preselected" :key="p.pk" type="info" class="preselected-tag">
{{ p.label }}
</el-tag>
</div>
</template>
<el-tree
v-else
ref="employeeTreeRef"
:data="employeeTree"
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'label', children: 'children' }"
class="batch-pos-tree"
@check="onEmployeeCheck"
/>
</div>
<!-- 职务选择区 -->
<div class="batch-pos-panel">
<div class="batch-pos-panel-title">职务(勾选左侧复选框选择,可多选)</div>
<el-tree
v-loading="loading"
:data="positionTree"
node-key="id"
show-checkbox
default-expand-all
:props="{ label: 'label', children: 'children' }"
class="batch-pos-tree"
@check="onPositionCheck"
/>
</div>
</div>
<template #footer>
<el-button @click="visible = false">取消</el-button>
<el-button type="danger" :loading="loading" @click="submit('remove')">
批量删除职务
</el-button>
<el-button type="primary" :loading="loading" @click="submit('add')">
批量新增职务
</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.batch-pos-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.batch-pos-panel {
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
padding: 8px;
min-height: 320px;
max-height: 420px;
display: flex;
flex-direction: column;
}
.batch-pos-panel-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
padding: 4px 4px 8px;
border-bottom: 1px dashed var(--el-border-color-lighter);
margin-bottom: 6px;
}
.batch-pos-tree {
flex: 1;
overflow: auto;
}
.batch-pos-preselected {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-content: flex-start;
overflow: auto;
}
.preselected-tag {
max-width: 100%;
}
</style>
+72 -8
View File
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import type { ColumnSchema } from '@/types/api'
import EntityPicker from './EntityPicker.vue'
import MultiEntityPicker from './MultiEntityPicker.vue'
import RichTextEditor from './RichTextEditor.vue'
import { get } from '@/api/request'
/**
* 新增/编辑弹窗表单(从 CrudTable.vue 拆出的独立组件)
@@ -74,6 +76,56 @@ async function onSubmit(): Promise<void> {
function onPickerLabel(field: string, label: string): void {
emit('update:label', field, label)
}
// === multiValue 多选(身兼数职等):MultiEntityPicker(标签 + 下拉搜索 + 浏览面板多选) ===
/** 弹窗打开时:multiValue 列字符串值拆数组(MultiEntityPicker 为多值模型,v-model 需数组) */
function initMultiValues(): void {
for (const col of props.columns) {
if (col.type !== 'entityPicker' || !col.multiValue) continue
const raw = props.formModel[col.field]
// 后端列表返回逗号分隔字符串 → 数组
if (typeof raw === 'string' && raw) {
props.formModel[col.field] = raw.split(',').map((s) => s.trim()).filter(Boolean)
} else if (raw == null) {
props.formModel[col.field] = []
}
}
}
/** MultiEntityPicker 选中变化:同步回显标签(逗号分隔) */
function onMultiLabel(field: string, label: string): void {
props.formModel[`${field}_label`] = label
}
/**
* 部门绑定通用部门(优先展示分组):表单存在 DeptUUID(部门员工场景)时,
* 按部门查绑定通用部门 UUID,供多职务选择框置顶展示该部门通用部门的职务。
*/
const preferredCategoryKey = ref('')
watch(
() => props.formModel.DeptUUID,
async (deptUuid: unknown) => {
if (!deptUuid) {
preferredCategoryKey.value = ''
return
}
try {
const res = await get<Record<string, unknown>>('/API/FormAPI/Get/Department', { DeptUUID: String(deptUuid) })
preferredCategoryKey.value = String((res as Record<string, unknown> | null)?.PositionCategoryUUID ?? '')
} catch {
preferredCategoryKey.value = ''
}
},
{ immediate: true },
)
watch(
() => props.visible,
(v) => {
if (v) initMultiValues()
},
)
</script>
<template>
@@ -97,16 +149,28 @@ function onPickerLabel(field: string, label: string): void {
:label="col.title"
:prop="col.field"
>
<!-- entityPicker:EntityPicker 组件(支持级联:dependsOn 字段值 + dependCompany 公司值) -->
<EntityPicker
v-if="col.type === 'entityPicker' && col.entityPicker"
<!-- multiValue:多选实体选择框(标签 + 下拉搜索 + 浏览面板),身兼数职等 -->
<MultiEntityPicker
v-if="col.type === 'entityPicker' && col.multiValue"
v-model="formModel[col.field]"
:config="col.entityPicker"
:existing-label="(formModel[`${col.field}_label`] as string) || (col.entityPicker.valueField === col.field ? (formModel[col.field] as string) : '')"
:config="col.entityPicker!"
:tenant="(formModel.TenantUUID as string) || ''"
:label-text="(formModel[`${col.field}_label`] as string) || ''"
:preferred-group-key="preferredCategoryKey"
:placeholder="col.placeholder"
:disabled="col.disabled"
:depend-value="col.entityPicker.dependsOn ? (formModel[col.entityPicker.dependsOn] as string) : undefined"
:depend-company-value="col.entityPicker.dependCompany ? (formModel.CompanyUUID as string) : undefined"
@update:label="(label: string) => onMultiLabel(col.field, label)"
/>
<!-- entityPicker:EntityPicker 组件(支持级联:dependsOn 字段值 + dependCompany 公司值) -->
<EntityPicker
v-else-if="col.type === 'entityPicker' && col.entityPicker && !col.multiValue"
v-model="formModel[col.field]"
:config="col.entityPicker!"
:existing-label="(formModel[`${col.field}_label`] as string) || (col.entityPicker!.valueField === col.field ? (formModel[col.field] as string) : '')"
:placeholder="col.placeholder"
:disabled="col.disabled"
:depend-value="col.entityPicker!.dependsOn ? (formModel[col.entityPicker!.dependsOn] as string) : undefined"
:depend-company-value="col.entityPicker!.dependCompany ? (formModel.CompanyUUID as string) : undefined"
@update:label="(label: string) => onPickerLabel(col.field, label)"
@submit-multi="(items) => emit('picker-multi', col.field, items)"
/>
+110 -27
View File
@@ -78,6 +78,8 @@ const props = defineProps<{
onCardIconClick?: (row: T) => void
/** 默认每页条数(默认 20;树形卡片等需全量数据的场景可调大,如 1000) */
defaultPageSize?: number
/** 新增前拦截回调:返回 false 阻止打开新增弹窗(如依赖父级筛选条件的页面需先选条件) */
beforeCreate?: () => boolean | void
}>()
// 表格实际使用的最大高度:显式 maxHeight 优先;
@@ -191,6 +193,8 @@ const draft = useDraftAutosave({
// 自动时间戳字段(CreateTime 等)预填当前 UTC 时间——不要求用户输入(后端仍以服务器时间为准);
// 若存在未提交草稿,提示恢复或放弃
async function onHandleCreate(): Promise<void> {
// 新增前拦截:父页面可通过 beforeCreate 前置校验(返回 false 阻止打开弹窗)
if (props.beforeCreate && props.beforeCreate() === false) return
handleCreate()
Object.assign(editingRow, inheritedQueryFields.value)
for (const col of columns.value) {
@@ -273,6 +277,16 @@ function entityLabel(row: Record<string, unknown>, field: string): string {
: s
}
// multiValue 实体选择器:回显 label 按顿号/逗号拆多个标签(身兼数职等场景)
function multiLabels(row: Record<string, unknown>, field: string): string[] {
const label = row[`${field}_label`] ?? row[`${field}__label`]
if (!label) return []
return String(label)
.split(/[、,]/)
.map((s) => s.trim())
.filter(Boolean)
}
// tags 渲染:字符串按分隔符拆分
function renderTags(value: unknown, separator = ','): string[] {
if (!value) return []
@@ -441,6 +455,14 @@ function isCardSelected(row: Record<string, unknown>): boolean {
const pk = row[getPkField() ?? '']
return selectedRows.value.some((r) => (r as Record<string, unknown>)[getPkField() ?? ''] === pk)
}
// 暴露给父组件:选中行 / 当前页数据 / 刷新(供工具栏插槽等批量操作使用)
defineExpose({
selectedRows,
dataList,
fetchData,
refresh: fetchData,
})
</script>
<template>
@@ -480,6 +502,13 @@ function isCardSelected(row: Record<string, unknown>): boolean {
<el-button @click="fetchData">
<el-icon><Refresh /></el-icon>刷新
</el-button>
<!-- 工具栏插槽:父页面注入批量操作按钮(如批量调整职务),透传选中行/数据/刷新 -->
<slot
name="toolbar"
:selected-rows="selectedRows"
:data-list="dataList"
:refresh="fetchData"
/>
</div>
</div>
</template>
@@ -518,10 +547,21 @@ function isCardSelected(row: Record<string, unknown>): boolean {
:sortable="col.sortable"
>
<template #default="{ row }">
<!-- entityPicker:显示回显标签 -->
<span v-if="col.type === 'entityPicker'">
{{ entityLabel(row as Record<string, unknown>, col.field) }}
</span>
<!-- entityPicker:显示回显标签;multiValue(多值)拆多个标签 -->
<template v-if="col.type === 'entityPicker'">
<template v-if="col.multiValue">
<el-tag
v-for="(lbl, i) in multiLabels(row as Record<string, unknown>, col.field)"
:key="i"
size="small"
effect="plain"
class="crud-multi-tag"
>
{{ lbl }}
</el-tag>
</template>
<span v-else>{{ entityLabel(row as Record<string, unknown>, col.field) }}</span>
</template>
<!-- status:el-tag 渲染 -->
<el-tag
v-else-if="col.type === 'status' && col.statusMap"
@@ -566,7 +606,12 @@ function isCardSelected(row: Record<string, unknown>): boolean {
</vxe-table>
<!-- ===== 卡片模式:卡片网格展示(替代表格) ===== -->
<div v-else-if="cardMode" v-loading="loading" class="card-grid">
<div
v-else-if="cardMode"
v-loading="loading"
class="card-grid"
:class="{ 'card-grid-empty': !loading && dataList.length === 0 }"
>
<!-- 空态 -->
<el-empty
v-if="!loading && dataList.length === 0"
@@ -582,6 +627,7 @@ function isCardSelected(row: Record<string, unknown>): boolean {
class="crud-card"
:class="{ 'crud-card-selectable': selectable && isCardSelected(node.row as Record<string, unknown>) }"
@click="handleRowClick(node.row as T)"
@dblclick="handleView(node.row as T)"
>
<!-- 卡片头部:复选 + 层级标签 + 标题 + 子节点数 -->
<div class="crud-card-header">
@@ -592,6 +638,7 @@ function isCardSelected(row: Record<string, unknown>): boolean {
:model-value="isCardSelected(node.row as Record<string, unknown>)"
@change="(v: unknown) => onCardCheck(node.row as T, Boolean(v))"
@click.stop
@dblclick.stop
/>
<el-tag
v-if="node.depth > 0"
@@ -607,6 +654,7 @@ function isCardSelected(row: Record<string, unknown>): boolean {
:class="{ 'crud-card-icon-clickable': !!props.onCardIconClick }"
:title="props.onCardIconClick ? '点击设置图标' : ''"
@click.stop="props.onCardIconClick?.(node.row as T)"
@dblclick.stop
>
<img
v-if="(node.row as Record<string, unknown>)[cardIconField]"
@@ -624,9 +672,6 @@ function isCardSelected(row: Record<string, unknown>): boolean {
{{ node.children.length }} 子级
</el-tag>
</div>
<el-button size="small" type="primary" @click.stop="handleView(node.row as T)">
<el-icon><View /></el-icon>查看
</el-button>
</div>
<!-- 卡片主体:字段键值列表 -->
<div class="crud-card-body">
@@ -649,12 +694,15 @@ function isCardSelected(row: Record<string, unknown>): boolean {
</span>
</div>
</div>
<!-- 卡片操作:编辑 / 删除 -->
<!-- 卡片操作:查看 / 编辑 / 删除 -->
<div class="crud-card-actions">
<el-button link type="primary" @click.stop="handleEdit(node.row as T)">
<el-button v-if="onView" link type="primary" @dblclick.stop @click.stop="handleView(node.row as T)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button link type="primary" @dblclick.stop @click.stop="handleEdit(node.row as T)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click.stop="handleDelete(node.row as T)">
<el-button link type="danger" @dblclick.stop @click.stop="handleDelete(node.row as T)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
@@ -669,6 +717,7 @@ function isCardSelected(row: Record<string, unknown>): boolean {
class="crud-card"
:class="{ 'crud-card-selectable': selectable && selectedRows.some((r) => (r as Record<string, unknown>)[getPkField() ?? ''] === (row as Record<string, unknown>)[getPkField() ?? '']) }"
@click="handleRowClick(row as T)"
@dblclick="handleView(row as T)"
>
<!-- 卡片头部:复选 + 标题 + 状态标签 -->
<div class="crud-card-header">
@@ -679,6 +728,7 @@ function isCardSelected(row: Record<string, unknown>): boolean {
:model-value="selectedRows.some((r) => (r as Record<string, unknown>)[getPkField() ?? ''] === (row as Record<string, unknown>)[getPkField() ?? ''])"
@change="(v: unknown) => onCardCheck(row as T, Boolean(v))"
@click.stop
@dblclick.stop
/>
<div
v-if="cardIconField"
@@ -686,6 +736,7 @@ function isCardSelected(row: Record<string, unknown>): boolean {
:class="{ 'crud-card-icon-clickable': !!props.onCardIconClick }"
:title="props.onCardIconClick ? '点击设置图标' : ''"
@click.stop="props.onCardIconClick?.(row as T)"
@dblclick.stop
>
<img
v-if="(row as Record<string, unknown>)[cardIconField]"
@@ -700,9 +751,6 @@ function isCardSelected(row: Record<string, unknown>): boolean {
</div>
<span class="crud-card-title">{{ (row as Record<string, unknown>)[cardTitleField] || '-' }}</span>
</div>
<el-button size="small" type="primary" @click.stop="handleView(row as T)">
<el-icon><View /></el-icon>查看
</el-button>
</div>
<!-- 卡片主体:字段键值列表 -->
<div class="crud-card-body">
@@ -725,12 +773,15 @@ function isCardSelected(row: Record<string, unknown>): boolean {
</span>
</div>
</div>
<!-- 卡片操作:编辑 / 删除 -->
<!-- 卡片操作:查看 / 编辑 / 删除 -->
<div class="crud-card-actions">
<el-button link type="primary" @click.stop="handleEdit(row as T)">
<el-button v-if="onView" link type="primary" @dblclick.stop @click.stop="handleView(row as T)">
<el-icon><View /></el-icon>查看
</el-button>
<el-button link type="primary" @dblclick.stop @click.stop="handleEdit(row as T)">
<el-icon><Edit /></el-icon>编辑
</el-button>
<el-button link type="danger" @click.stop="handleDelete(row as T)">
<el-button link type="danger" @dblclick.stop @click.stop="handleDelete(row as T)">
<el-icon><Delete /></el-icon>删除
</el-button>
</div>
@@ -852,18 +903,33 @@ function isCardSelected(row: Record<string, unknown>): boolean {
// 卡片网格:响应式列数,min-width 自动换行
// 树状依赖模式复用同一网格:节点按 DFS 前序顺序输出,顺序即"父→子→孙"
// 卡片网格:固定列宽(300px),宽度变化只改变每行列数,不影响卡片内部布局。
// 固定宽度保证:短文本换行数恒定 → 卡片高度恒定,不随容器宽度波动。
// min(300px, 100%) 处理容器极窄时的收缩,避免横向溢出。
.card-grid {
flex: 1;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(300px, 100%), 300px));
gap: 14px;
padding: 2px;
align-content: start;
// 卡片按自身内容高度对齐,不拉伸到同行最高卡片(高度完全由内容决定)
align-items: start;
}
// 单张卡片
// 空态:无卡片数据时切换 flex 布局,el-empty 水平垂直居中
// (grid 布局下 auto 行轨道高度=内容高度,margin:auto 无法垂直居中,故单独切 flex)
.card-grid-empty {
display: flex;
align-items: center;
justify-content: center;
}
// 单张卡片:高度由自身内容决定(header + body + actions 自然堆叠)
// 不设 overflow:hidden,避免任何外部拉伸/压缩裁剪内容;
// 网格父级 align-items:start 保证不拉伸,flex 纵向排列内容完整可见。
.crud-card {
display: flex;
flex-direction: column;
@@ -873,7 +939,6 @@ function isCardSelected(row: Record<string, unknown>): boolean {
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
overflow: hidden;
&:hover {
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
@@ -967,9 +1032,9 @@ function isCardSelected(row: Record<string, unknown>): boolean {
transition: opacity 0.15s;
}
// 卡片主体:键值列表
// 卡片主体:键值列表(高度由内容决定,不参与拉伸/压缩)
.crud-card-body {
flex: 1;
flex: none;
padding: 10px 14px;
display: flex;
flex-direction: column;
@@ -981,6 +1046,16 @@ function isCardSelected(row: Record<string, unknown>): boolean {
justify-content: space-between;
align-items: baseline;
gap: 12px;
min-width: 0;
}
// multiValue 多值标签(表格列内多个职务/多标签)
.crud-multi-tag {
margin-right: 6px;
margin-bottom: 2px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.crud-card-field-label {
@@ -993,18 +1068,26 @@ function isCardSelected(row: Record<string, unknown>): boolean {
text-align: right;
color: var(--el-text-color-primary);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
word-break: break-word;
overflow-wrap: anywhere;
white-space: normal;
line-height: 1.45;
}
// 卡片操作:编辑 / 删除
// 卡片操作:查看 / 编辑 / 删除(补底部圆角,避免背景色盖住卡片圆角变尖角)
// 重置 EP 的 .el-button + .el-button { margin-left: 12px },改用 flex gap 统一等距
.crud-card-actions {
display: flex;
justify-content: flex-end;
gap: 4px;
gap: 8px;
padding: 6px 10px;
border-top: 1px solid var(--el-border-color-lighter);
background: var(--el-fill-color-light);
border-radius: 0 0 10px 10px;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
</style>
+724
View File
@@ -0,0 +1,724 @@
<script setup lang="ts">
/**
* MultiEntityPicker 多选实体选择框
*
* 供 multiValue 列(如 UserRoleDepartment 职务"身兼数职")使用的实体选择器:
* - 主输入:el-select multiple filterable remote(标签 + 输入搜索,选中项折叠为标签)
* - 浏览按钮:弹出面板列出全部可见实体(分页"加载更多"),点击行勾选/取消,实时生效
*
* 与 EntityPicker 的区别:本组件是多值模型(v-model 为 string[]),供 multiValue 列使用。
* 响应拦截器已解包 {code,data}:兼容纯数组(PositionSearch)与 {list,has_more}(分页 Search)。
*/
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import type { EntityPickerConfig } from '@/types/api'
import { get } from '@/api/request'
interface PickerOption {
value: string
label: string
/** 树分组字段值(treeGroup 模式:如 CategoryUUID),用于归入通用部门分组 */
groupKey?: string
}
/** 树分组节点(通用部门树:uuid/name/parentUuid 扁平结构,前端组装) */
interface TreeGroupNode {
uuid: string
name: string
parentUuid?: string
}
/** 树节点视图模型 */
interface TreeNodeVM {
key: string
title: string
children: TreeNodeVM[]
}
const props = withDefaults(defineProps<{
/** 已选值数组(v-model) */
modelValue?: string[]
/** 搜索配置 */
config: EntityPickerConfig
/** 是否禁用 */
disabled?: boolean
/** 占位符(覆盖 config.placeholder) */
placeholder?: string
/** 租户过滤:非空时搜索/浏览均按此租户过滤(多选职务按记录所属租户) */
tenant?: string
/** 回显标签(逗号/顿号分隔,与 modelValue 对齐) */
labelText?: string
/** 优先展示的分组 key(如部门绑定的通用部门 UUID):该分组置顶展示,其它分组仍可选 */
preferredGroupKey?: string
}>(), {
modelValue: () => [],
disabled: false,
placeholder: '',
tenant: '',
labelText: '',
preferredGroupKey: '',
})
const emit = defineEmits<{
(e: 'update:modelValue', v: string[]): void
/** 回显标签(逗号分隔,与选中值对齐) */
(e: 'update:label', label: string): void
}>()
const value = computed<string[]>({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const placeholderText = computed(() => props.placeholder || props.config.placeholder || '请选择(可多选)')
// ===== 主下拉选项 =====
const options = ref<PickerOption[]>([])
const loading = ref(false)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
/** 已有回显项(value → label) */
const echoMap = ref<Map<string, string>>(new Map())
/** 解析回显标签并补充 options(保证已选值在 el-select 中有对应 option 可显示标签)。
* labelText 仅与已保存的选中值对齐;新选中的值没有对应 label 时**不生成 label=value 的
* option**(否则会覆盖搜索下拉返回的正确名称 option,导致 tag 显示 UUID)。 */
function rebuildEcho(): void {
const labels = props.labelText.split(/[、,]/).map((s) => s.trim()).filter(Boolean)
const map = new Map<string, string>()
props.modelValue.forEach((v, i) => {
if (v && labels[i]) map.set(v, labels[i])
})
echoMap.value = map
const list: PickerOption[] = [...map.entries()].map(([v, l]) => ({ value: v, label: l }))
options.value = [...list, ...options.value.filter((o) => !map.has(o.value))]
}
watch(() => props.modelValue, rebuildEcho)
watch(() => props.labelText, rebuildEcho)
/** 合并选项(已选回显 + 搜索结果,按 value 去重) */
function mergeOptions(list: PickerOption[]): void {
const map = new Map<string, PickerOption>()
for (const o of [...options.value, ...list]) map.set(o.value, o)
options.value = [...map.values()]
}
/** 请求 Search 接口(拦截器已解包 {code,data}),兼容纯数组与 {list,has_more} */
async function fetchOptions(keyword: string): Promise<{ list: PickerOption[]; hasMore: boolean; tree: TreeGroupNode[] }> {
const params: Record<string, unknown> = { keyword }
if (props.tenant) params.TenantUUID = props.tenant
// treeGroup 模式:请求附带 tree=1,后端返回通用部门树 + 职务归组
if (props.config.treeGroup) params.tree = '1'
const res = await get<unknown>(props.config.api, params)
let list: unknown = res
let hasMore = false
let tree: TreeGroupNode[] = []
if (res && typeof res === 'object' && !Array.isArray(res)) {
const obj = res as Record<string, unknown>
if ('list' in obj) list = obj.list
else if (props.config.resultPath) list = obj[props.config.resultPath]
if (typeof obj.has_more === 'boolean') hasMore = obj.has_more
// 树节点:treeGroup 配置指定 treeField(默认 'tree')
if (props.config.treeGroup) {
const raw = obj[props.config.treeGroup.treeField || 'tree']
if (Array.isArray(raw)) {
tree = raw.map((n) => {
const node = n as Record<string, unknown>
return {
uuid: String(node.uuid ?? node.UUID ?? ''),
name: String(node.name ?? node.label ?? ''),
parentUuid: node.parentUuid ? String(node.parentUuid) : undefined,
}
})
}
}
}
const arr = Array.isArray(list) ? list : []
const groupField = props.config.treeGroup?.groupField
return {
list: arr.map((it) => {
const row = it as Record<string, unknown>
return {
value: String(row[props.config.valueField] ?? ''),
label: String(row[props.config.labelField] ?? ''),
groupKey: groupField ? String(row[groupField] ?? '') : undefined,
}
}),
hasMore,
tree,
}
}
/** 替换选项:保留已选回显 + 传入列表(搜索时替换,避免旧选项残留导致"看起来没过滤") */
function replaceOptions(list: PickerOption[]): void {
const keep = options.value.filter((o) => echoMap.value.has(o.value))
const map = new Map<string, PickerOption>()
for (const o of [...keep, ...list]) map.set(o.value, o)
options.value = [...map.values()]
}
async function doSearch(keyword: string): Promise<void> {
loading.value = true
try {
const { list } = await fetchOptions(keyword)
replaceOptions(list)
} catch {
/* 搜索失败保持现有选项 */
} finally {
loading.value = false
}
}
function remoteSearch(keyword: string): void {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => void doSearch(keyword), 300)
}
/** 初始/打开时预加载(空关键词=列出全部),保证 remote 多选打开即可选 */
function preload(): void {
rebuildEcho()
void doSearch('')
}
// ===== 浏览面板 =====
const browseVisible = ref(false)
const browseKeyword = ref('')
const browseList = ref<PickerOption[]>([])
const browseLoading = ref(false)
const browseLoadingMore = ref(false)
const browseHasMore = ref(false)
const browseLoadedCount = ref(0)
/** 面板内通用部门树(treeGroup 模式) */
const browseTree = ref<TreeGroupNode[]>([])
/** 面板内组装好的树节点(el-tree 用) */
const browseTreeNodes = ref<TreeNodeVM[]>([])
/** 暂存区:面板内勾选的职务,点"确认选择"统一提交 */
const browseStaged = ref<PickerOption[]>([])
/**
* 暂存区是否可提交空(清空职务):
* 打开面板时已有已选 或 本次会话添加过勾选 → 允许全部取消后提交空(从 >0 撤销到 0 是合法操作);
* 从未选过任何项 → 禁止空提交(0 到 0 无意义,按钮保持禁用)。
*/
const stagedTouched = ref(false)
/** 面板内是否已勾选(暂存区包含) */
function isStaged(opt: PickerOption): boolean {
return browseStaged.value.some((o) => o.value === opt.value)
}
/** 切换勾选(进/出暂存区) */
function toggleStaged(opt: PickerOption): void {
const idx = browseStaged.value.findIndex((o) => o.value === opt.value)
if (idx >= 0) browseStaged.value.splice(idx, 1)
else {
browseStaged.value.push(opt)
stagedTouched.value = true // 本次会话添加过勾选 → 允许后续清空提交
}
}
/** 从暂存区移除(顶部已选标签 ×) */
function removeStaged(opt: PickerOption): void {
const idx = browseStaged.value.findIndex((o) => o.value === opt.value)
if (idx >= 0) browseStaged.value.splice(idx, 1)
}
/** 确认选择:暂存区统一提交到已选并关闭面板(允许从 >0 撤销到 0 的清空提交) */
function confirmStaged(): void {
if (!browseStaged.value.length && !stagedTouched.value) return
const next = browseStaged.value.map((o) => o.value)
emit('update:modelValue', next)
// 并入主下拉 options(保证主输入框标签可显示)+ 回显 label
mergeOptions(browseStaged.value)
emitLabel(next)
browseVisible.value = false
browseStaged.value = []
stagedTouched.value = false
}
/** treeGroup 模式下:职务按通用部门 UUID 分组(key=部门UUID 或 '' 未分组) */
const browseGroupedMembers = computed<Record<string, PickerOption[]>>(() => {
const map: Record<string, PickerOption[]> = {}
for (const opt of browseList.value) {
const key = opt.groupKey || ''
if (!map[key]) map[key] = []
map[key].push(opt)
}
return map
})
/** 将扁平通用部门树节点组装为嵌套结构 */
function buildTreeNodes(nodes: TreeGroupNode[]): TreeNodeVM[] {
const byUuid = new Map<string, TreeNodeVM>()
const roots: TreeNodeVM[] = []
for (const n of nodes) {
byUuid.set(n.uuid, { key: n.uuid, title: n.name, children: [] })
}
for (const n of nodes) {
const vm = byUuid.get(n.uuid)
if (!vm) continue
const parent = n.parentUuid ? byUuid.get(n.parentUuid) : undefined
if (parent) parent.children.push(vm)
else roots.push(vm)
}
return roots
}
/** 树节点展示顺序:绑定的通用部门分组置顶(优先展示),其余按原序 */
const orderedTreeNodes = computed<TreeNodeVM[]>(() => {
const nodes = browseTreeNodes.value
if (!props.preferredGroupKey || nodes.length <= 1) return nodes
const idx = nodes.findIndex((n) => n.key === props.preferredGroupKey)
if (idx <= 0) return nodes
const next = [...nodes]
const [pref] = next.splice(idx, 1)
return [pref, ...next]
})
function openBrowse(): void {
if (props.disabled) return
browseVisible.value = true
browseKeyword.value = ''
browseList.value = []
browseTree.value = []
browseTreeNodes.value = []
browseHasMore.value = false
browseLoadedCount.value = 0
// 暂存区初始化为当前已选(面板勾选基于暂存区,确认才生效)
browseStaged.value = props.modelValue
.map((v) => options.value.find((o) => o.value === v) || { value: v, label: labelOf(v) })
.filter((o) => !!o.value)
// 打开时已有已选 → 允许全部取消后提交空(清空职务)
stagedTouched.value = props.modelValue.length > 0
void loadBrowsePage(0, '')
}
function browseSearch(): void {
browseList.value = []
browseLoadedCount.value = 0
browseHasMore.value = false
void loadBrowsePage(0, browseKeyword.value.trim())
}
async function loadBrowsePage(offset: number, keyword: string): Promise<void> {
if (offset === 0) browseLoading.value = true
else browseLoadingMore.value = true
try {
const { list, hasMore, tree } = await fetchOptions(keyword)
const existing = new Set(browseList.value.map((o) => o.value))
browseList.value = offset === 0 ? list : [...browseList.value, ...list.filter((o) => !existing.has(o.value))]
// treeGroup 模式:保存通用部门树(仅首屏加载,后续分页不覆盖)
if (tree.length && offset === 0) {
browseTree.value = tree
browseTreeNodes.value = buildTreeNodes(tree)
}
browseHasMore.value = hasMore
browseLoadedCount.value += list.length
} catch {
if (offset === 0) browseList.value = []
} finally {
browseLoading.value = false
browseLoadingMore.value = false
}
}
function browseLoadMore(): void {
if (browseLoadingMore.value || !browseHasMore.value) return
void loadBrowsePage(browseLoadedCount.value, browseKeyword.value.trim())
}
function labelOf(v: string): string {
return options.value.find((o) => o.value === v)?.label || echoMap.value.get(v) || v.slice(0, 8) + '...'
}
function emitLabel(vals: string[]): void {
emit('update:label', vals.map((v) => labelOf(v)).join(','))
}
onMounted(() => {
// 弹窗 destroy-on-close 每次打开重建组件:挂载即预加载,保证 remote 多选打开即可选
preload()
})
onBeforeUnmount(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
</script>
<template>
<div class="multi-entity-picker">
<div class="multi-entity-picker-input">
<el-select
v-model="value"
multiple
filterable
remote
collapse-tags
collapse-tags-tooltip
:remote-method="remoteSearch"
:loading="loading"
:placeholder="placeholderText"
:disabled="disabled"
>
<el-option
v-for="opt in options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-button
class="multi-entity-picker-browse-btn"
:disabled="disabled"
title="浏览全部,无需输入"
@click="openBrowse"
>
<el-icon><Search /></el-icon>
</el-button>
</div>
<!-- 浏览面板:列出可见范围内全部实体,勾选进暂存区,点"确认选择"生效 -->
<el-dialog
v-model="browseVisible"
:title="`选择${placeholderText}`"
width="560px"
append-to-body
destroy-on-close
>
<div class="multi-entity-picker-browse">
<!-- 当前暂存:勾选待确认的职务标签列表,可移除(始终显示,避免布局跳动) -->
<div class="multi-entity-picker-browse-current">
<span class="multi-entity-picker-browse-current-title">待确认({{ browseStaged.length }})</span>
<el-tag
v-for="opt in browseStaged"
:key="opt.value"
closable
size="small"
@close="removeStaged(opt)"
>
{{ opt.label }}
</el-tag>
<span v-if="!browseStaged.length" class="multi-entity-picker-browse-current-empty">尚未勾选职务</span>
</div>
<div class="multi-entity-picker-browse-search">
<el-input
v-model="browseKeyword"
placeholder="输入关键字搜索,留空展示全部"
clearable
@keyup.enter="browseSearch"
>
<template #append>
<el-button @click="browseSearch">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<div v-loading="browseLoading" class="multi-entity-picker-browse-list">
<el-empty
v-if="!browseLoading && browseList.length === 0 && !(config.treeGroup && browseTreeNodes.length)"
description="暂无数据"
:image-size="60"
/>
<!-- treeGroup 模式:通用部门 → 职务 两级结构(绑定部门置顶优先展示) -->
<template v-if="config.treeGroup">
<div v-if="orderedTreeNodes.length" class="multi-entity-picker-tree">
<div
v-for="dept in orderedTreeNodes"
:key="dept.key"
:class="['multi-entity-picker-tree-branch', { 'is-preferred': dept.key === preferredGroupKey }]"
>
<div class="multi-entity-picker-tree-cat">
<el-icon><OfficeBuilding /></el-icon>
<span>{{ dept.title }}</span>
<el-tag v-if="dept.key === preferredGroupKey" size="small" type="primary" effect="dark" class="multi-entity-picker-tree-cat-tag">
本部门
</el-tag>
</div>
<div class="multi-entity-picker-tree-members">
<!-- 本部门直属职务 -->
<div
v-for="opt in browseGroupedMembers[dept.key] || []"
:key="opt.value"
:class="['multi-entity-picker-browse-item', 'multi-entity-picker-tree-member', { 'is-selected': isStaged(opt) }]"
@click="toggleStaged(opt)"
>
<span class="multi-entity-picker-browse-label">{{ opt.label }}</span>
<el-icon v-if="isStaged(opt)" class="multi-entity-picker-browse-check"><Check /></el-icon>
</div>
<!-- 子部门递归 -->
<div
v-for="child in dept.children"
:key="child.key"
class="multi-entity-picker-tree-branch"
>
<div class="multi-entity-picker-tree-cat">
<el-icon><OfficeBuilding /></el-icon>
<span>{{ child.title }}</span>
</div>
<div class="multi-entity-picker-tree-members">
<div
v-for="opt in browseGroupedMembers[child.key] || []"
:key="opt.value"
:class="['multi-entity-picker-browse-item', 'multi-entity-picker-tree-member', { 'is-selected': isStaged(opt) }]"
@click="toggleStaged(opt)"
>
<span class="multi-entity-picker-browse-label">{{ opt.label }}</span>
<el-icon v-if="isStaged(opt)" class="multi-entity-picker-browse-check"><Check /></el-icon>
</div>
</div>
</div>
</div>
</div>
<!-- 未归属通用部门的职务兜底 -->
<div v-if="(browseGroupedMembers[''] || []).length" class="multi-entity-picker-tree-branch">
<div class="multi-entity-picker-tree-cat">
<el-icon><OfficeBuilding /></el-icon>
<span>{{ config.treeGroup.ungroupedTitle || '未分组' }}</span>
</div>
<div class="multi-entity-picker-tree-members">
<div
v-for="opt in browseGroupedMembers['']"
:key="opt.value"
:class="['multi-entity-picker-browse-item', 'multi-entity-picker-tree-member', { 'is-selected': isStaged(opt) }]"
@click="toggleStaged(opt)"
>
<span class="multi-entity-picker-browse-label">{{ opt.label }}</span>
<el-icon v-if="isStaged(opt)" class="multi-entity-picker-browse-check"><Check /></el-icon>
</div>
</div>
</div>
</div>
<!-- 树加载了但无成员 -->
<el-empty
v-else-if="!browseLoading && browseTreeNodes.length === 0 && browseList.length === 0"
description="暂无通用部门或职务"
:image-size="60"
/>
</template>
<!-- 普通模式:扁平列表 -->
<template v-else>
<div
v-for="opt in browseList"
:key="opt.value"
:class="['multi-entity-picker-browse-item', { 'is-selected': isStaged(opt) }]"
@click="toggleStaged(opt)"
>
<span class="multi-entity-picker-browse-label">{{ opt.label }}</span>
<el-icon v-if="isStaged(opt)" class="multi-entity-picker-browse-check"><Check /></el-icon>
</div>
</template>
</div>
<div v-if="browseHasMore" class="multi-entity-picker-browse-more">
<el-button text type="primary" :loading="browseLoadingMore" @click="browseLoadMore">
加载更多
</el-button>
</div>
<!-- 确认选择:暂存区统一提交 -->
<div class="multi-entity-picker-browse-confirm">
<el-button type="primary" :disabled="!browseStaged.length && !stagedTouched" @click="confirmStaged">
确认选择({{ browseStaged.length }})
</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<style scoped>
.multi-entity-picker {
width: 100%;
}
.multi-entity-picker-input {
display: flex;
gap: 4px;
width: 100%;
.el-select {
flex: 1;
}
}
.multi-entity-picker-browse-btn {
flex-shrink: 0;
}
/* 深色模式:浏览按钮 hover 用半透明主色背景(避免默认 hover 偏白) */
html.dark {
.multi-entity-picker-browse-btn {
&:hover {
background: color-mix(in srgb, var(--el-color-primary) 16%, transparent);
color: var(--el-color-primary-light-3);
}
}
}
.multi-entity-picker-browse {
display: flex;
flex-direction: column;
gap: 12px;
}
/* 当前已选标签行 */
.multi-entity-picker-browse-current {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 6px 10px;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
background: var(--el-fill-color-light);
max-height: 120px;
overflow-y: auto;
}
.multi-entity-picker-browse-current-title {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.multi-entity-picker-browse-current-empty {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.multi-entity-picker-browse-search {
flex-shrink: 0;
}
.multi-entity-picker-browse-list {
min-height: 220px;
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
padding: 4px;
box-sizing: border-box;
}
.multi-entity-picker-browse-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background: var(--el-color-primary-light-9);
}
}
html.dark {
.multi-entity-picker-browse-item {
&:hover {
background: color-mix(in srgb, var(--el-color-primary) 16%, transparent);
}
}
}
.multi-entity-picker-browse-label {
font-size: 14px;
color: var(--el-text-color-primary);
}
/* ===== 通用部门树:通用部门 → 职务 ===== */
.multi-entity-picker-tree {
display: flex;
flex-direction: column;
gap: 2px;
}
.multi-entity-picker-tree-branch {
margin: 2px 0;
}
.multi-entity-picker-tree-cat {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
border-radius: 4px;
margin-bottom: 2px;
}
.multi-entity-picker-tree-members {
padding-left: 18px;
}
/* 优先展示分组(部门绑定的通用部门):标题行主色强调 */
.multi-entity-picker-tree-branch.is-preferred > .multi-entity-picker-tree-cat {
background: color-mix(in srgb, var(--el-color-primary) 14%, var(--el-fill-color-light));
border: 1px solid color-mix(in srgb, var(--el-color-primary) 45%, transparent);
border-radius: 4px;
}
.multi-entity-picker-tree-cat-tag {
margin-left: auto;
}
.multi-entity-picker-tree-member {
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.multi-entity-picker-tree-member:last-child {
border-bottom: none;
}
.multi-entity-picker-browse-check {
color: var(--el-color-primary);
font-size: 16px;
}
.multi-entity-picker-browse-item.is-selected {
background: var(--el-color-primary-light-8);
}
html.dark {
.multi-entity-picker-browse-item.is-selected {
background: color-mix(in srgb, var(--el-color-primary) 24%, transparent);
}
}
.multi-entity-picker-browse-more {
flex-shrink: 0;
text-align: center;
}
.multi-entity-picker-browse-confirm {
flex-shrink: 0;
text-align: right;
}
/* 未勾选(0)时 disabled 态:灰底 + 次文本色,文字与背景区分明显(默认 primary disabled 在深色下对比弱) */
.multi-entity-picker-browse-confirm .el-button.is-disabled {
--el-button-disabled-bg-color: var(--el-fill-color-light);
--el-button-disabled-border-color: var(--el-border-color);
--el-button-disabled-text-color: var(--el-text-color-secondary);
opacity: 1;
}
/* dark 主题:选中/待确认 el-tag 用半透明主色(light 效果在深色下过亮) */
html.dark {
.multi-entity-picker :deep(.el-select__tags .el-tag),
.multi-entity-picker-browse-current .el-tag {
--el-tag-bg-color: color-mix(in srgb, var(--el-color-primary) 22%, transparent);
--el-tag-border-color: color-mix(in srgb, var(--el-color-primary) 50%, transparent);
--el-tag-text-color: var(--el-color-primary-light-3);
--el-tag-hover-color: var(--el-color-primary-light-3);
}
}
</style>
+21 -5
View File
@@ -89,12 +89,17 @@ async function toggleDefault(role: RoleItem): Promise<void> {
}
}
/** 角色描述:公司全名(公司简称),无公司时显示"无所属公司" */
/** 角色副标题:只显示角色原名(Role.RoleName)与所属公司(简称),不显示覆盖名 */
function roleDesc(role: RoleItem): string {
if (role.CompanyName) {
return role.CompanyShort ? `${role.CompanyName} (${role.CompanyShort})` : role.CompanyName
}
return '无所属公司'
const original = role.RoleNameOriginal || ''
const company = role.CompanyName
? role.CompanyShort
? `${role.CompanyName} (${role.CompanyShort})`
: role.CompanyName
: ''
if (original && company) return `${original} · ${company}`
if (original) return original
return company || '无所属公司'
}
</script>
@@ -416,6 +421,17 @@ function roleDesc(role: RoleItem): string {
background: var(--el-fill-color-light);
}
/* 移动端:对话框加宽,角色卡片单列占满(min-width:240px 双列会溢出右侧) */
@media (max-width: 768px) {
.role-switcher-content {
width: 94%;
}
.role-card {
width: 100%;
min-width: 0;
}
}
.gerp-btn {
padding: 8px 16px;
border-radius: 8px;
+135 -18
View File
@@ -92,6 +92,10 @@ function goNotify(): void {
onMounted(() => {
appStore.initTheme()
// 移动端检测:窄屏(<768px)侧边栏变抽屉,不占主内容
mql = window.matchMedia('(max-width: 768px)')
updateIsMobile(mql)
mql.addEventListener('change', updateIsMobile)
// 获取角色列表(用于切换身份对话框)
if (userStore.isLoggedIn) {
userStore.fetchRoles().catch(() => {
@@ -107,8 +111,20 @@ onMounted(() => {
onBeforeUnmount(() => {
if (unreadTimer) clearInterval(unreadTimer)
mql?.removeEventListener('change', updateIsMobile)
})
// === 移动端抽屉侧边栏 ===
const isMobile = ref(false)
const mobileMenuOpen = ref(false)
let mql: MediaQueryList | null = null
/** matchMedia 回调:切回桌面端时关闭抽屉 */
function updateIsMobile(e: MediaQueryList | MediaQueryListEvent): void {
isMobile.value = e.matches
if (!e.matches) mobileMenuOpen.value = false
}
// 路由切换后延迟刷新未读数(站内信页内已读/删除后返回,角标立即更新)
watch(
() => route.path,
@@ -152,10 +168,14 @@ async function refreshMenus(): Promise<void> {
function handleMenuSelect(index: string): void {
router.push(index)
// 移动端抽屉:选中菜单后关闭抽屉
if (isMobile.value) mobileMenuOpen.value = false
}
/** 折叠态下点击侧边栏任意位置(菜单项/子菜单标题),自动展开侧边栏 */
function handleSidebarClick(): void {
// 移动端抽屉点击不触发桌面端折叠逻辑
if (isMobile.value) return
if (appStore.sidebarCollapsed) {
appStore.toggleSidebar()
}
@@ -214,7 +234,12 @@ async function handleLogout(): Promise<void> {
}
function handleToggleSidebar(): void {
appStore.toggleSidebar()
// 移动端:汉堡按钮切换抽屉(遮罩上的侧边栏);桌面端:折叠/展开侧边栏
if (isMobile.value) {
mobileMenuOpen.value = !mobileMenuOpen.value
} else {
appStore.toggleSidebar()
}
}
function handleToggleTheme(): void {
@@ -252,7 +277,7 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
<el-icon :size="18"><Menu /></el-icon>
</el-button>
<div class="gerp-logo">
<img src="/favicon.svg" alt="logo" class="gerp-logo-icon" />
<img src="https://hb.cdn.gvsds.com/assets/img/GERP/GERP.png" alt="logo" class="gerp-logo-icon" />
<span class="gerp-logo-text">GERP 管理系统</span>
</div>
</div>
@@ -340,7 +365,10 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
<div class="gerp-main">
<!-- 侧边栏 -->
<aside class="gerp-sidebar" :class="{ collapsed: appStore.sidebarCollapsed }">
<aside
class="gerp-sidebar"
:class="{ collapsed: !isMobile && appStore.sidebarCollapsed, 'mobile-open': isMobile && mobileMenuOpen }"
>
<el-scrollbar>
<el-menu
ref="menuRef"
@@ -387,10 +415,12 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
circle
size="small"
title="强制刷新菜单(应用安装/卸载后同步侧边栏)"
:loading="menuRefreshing"
:disabled="menuRefreshing"
@click="refreshMenus"
>
<el-icon><Refresh /></el-icon>
<!-- 刷新中:图标原位替换为加载等待,完成后再还原为刷新图标 -->
<el-icon v-if="menuRefreshing" class="is-loading"><Loading /></el-icon>
<el-icon v-else><Refresh /></el-icon>
</el-button>
<el-button text circle size="small" title="设置" @click="router.push('/system/settings')">
<el-icon><Setting /></el-icon>
@@ -399,6 +429,15 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
</div>
</aside>
<!-- 移动端抽屉遮罩:点击关闭抽屉(淡入淡出) -->
<transition name="gerp-mask">
<div
v-if="isMobile && mobileMenuOpen"
class="gerp-mobile-mask"
@click="mobileMenuOpen = false"
></div>
</transition>
<!-- 主内容区 -->
<main class="gerp-content">
<!-- 多标签页栏 -->
@@ -591,12 +630,68 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
overflow: hidden;
}
// 移动端(<768px):侧边栏变抽屉(fixed 脱离布局不占主内容),
// 默认滑出屏幕外,点击汉堡按钮 + 遮罩滑入(transform 过渡在基础样式已声明)
@media (max-width: 768px) {
.gerp-sidebar {
position: fixed;
top: var(--gerp-header-height);
bottom: 0;
left: 0;
width: 260px;
z-index: 2000;
transform: translateX(-100%);
box-shadow: none;
&.mobile-open {
transform: translateX(0);
box-shadow: 4px 0 16px rgba(0, 0, 0, 0.15);
}
}
// 移动端 nav 折叠:隐藏面包屑与用户名,logo 文字保留并强制单行
.gerp-breadcrumb {
display: none;
}
.gerp-user-name {
display: none;
}
.gerp-role-badge {
display: none;
}
.gerp-logo-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40vw;
}
}
// 移动端抽屉遮罩(点击关闭),淡入淡出过渡
.gerp-mobile-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 1999;
}
.gerp-mask-enter-active,
.gerp-mask-leave-active {
transition: opacity 0.3s ease;
}
.gerp-mask-enter-from,
.gerp-mask-leave-to {
opacity: 0;
}
.gerp-sidebar {
width: var(--gerp-sidebar-width);
flex-shrink: 0;
background: var(--el-bg-color);
border-right: 1px solid var(--el-border-color-light);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
// width 过渡(桌面折叠)+ transform 过渡(移动端抽屉滑入滑出)
transition:
width 0.3s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
display: flex;
flex-direction: column;
@@ -625,9 +720,23 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
max-width: 160px;
}
// sub-menu 箭头:折叠时随文字一起淡出,展开时淡入
// sub-menu 箭头:折叠时随文字一起淡出,展开时淡入;
// 箭头终点方向与 EP 默认相反:收起态朝上(↑),展开态朝下(↓)。
// EP 内联 style 控制 transform,需 !important 覆盖。
:deep(.el-sub-menu__icon-arrow) {
transition: opacity 0.25s ease;
transition: transform 0.3s cubic-bezier(0.25, 0.1, 0.25, 1), opacity 0.25s ease;
// EP 默认 margin-top: -6px(垂直居中),上移 5px 再下移 1px = 净上移 4px;
// 默认 right: 20px,右移 4px 使箭头更靠右(= 16px)
margin-top: -10px;
right: 16px;
}
// 收起态:箭头顺时针旋转 90°(ArrowDown 朝下 → 朝左,指示可展开)
:deep(.el-sub-menu > .el-sub-menu__title .el-sub-menu__icon-arrow) {
transform: rotateZ(90deg) !important;
}
// 展开态:箭头朝下(↓)
:deep(.el-sub-menu.is-opened > .el-sub-menu__title .el-sub-menu__icon-arrow) {
transform: rotateZ(0deg) !important;
}
// 一级菜单(有二级菜单的):覆盖 Element Plus 默认 min-width: 200px,允许跟随容器收缩
@@ -653,18 +762,21 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
width: 100%;
min-width: 0;
}
// 折叠态:菜单项与 sub-menu 标题图标居中(icon 18px,(64-18)/2=23)
:deep(.el-menu-item) {
width: 100%;
min-width: 0;
padding-left: 23px;
padding-right: 23px;
}
// 折叠态:菜单项/标题为胶囊。保持展开态 margin(2px 10px) 与 width 语义一致,
// 图标位移完全由 padding-left 20px→10px 驱动(padding 可过渡 → 平滑左移居中),
// 不依赖 justify-content(CSS 无法过渡,会导致图标瞬间跳动)。
// 24px 图标盒在 44px li 内居中:(44-24)/2 = 10px
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
width: 100%;
width: calc(100% - 20px);
min-width: 0;
padding-left: 23px;
padding-right: 23px;
margin: 2px 10px;
padding-left: 10px;
padding-right: 10px;
}
// 二级菜单项同样去掉水平 margin
:deep(.el-menu--inline .el-menu-item) {
margin: 1px 0;
}
// 二级菜单容器宽度锁定为容器宽
:deep(.el-menu--inline) {
@@ -679,6 +791,11 @@ const isFaIcon = (icon: string | undefined | null): boolean => {
:deep(.el-sub-menu__icon-arrow) {
opacity: 0;
}
// 折叠态 el 图标字形相对 fa 视觉偏右,左移 3px 对齐(展开态保持正常)
:deep(.el-menu-item > .el-icon),
:deep(.el-sub-menu__title > .el-icon) {
transform: translateX(-3px);
}
:deep(.el-menu--inline) {
max-height: 0;
}
+28
View File
@@ -1,4 +1,5 @@
import { createApp } from 'vue'
import { ElMessage } from 'element-plus'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// Element Plus 暗色主题 CSS 变量(html.dark 时生效:卡片/导航/输入框等自动变暗)
@@ -20,6 +21,33 @@ if (window.location.pathname !== '/' && window.location.hash) {
window.history.replaceState({}, '', '/' + window.location.hash)
}
// === 全局资源加载失败提示(js/css 404/502 等静默失败兜底) ===
// 捕获阶段监听资源加载错误:script/link 加载失败时贴出报错提示(按资源去重防抖),
// 避免"页面白屏但无任何报错"的静默失败。捕获阶段可拿到资源元素,冒泡阶段 Error 事件无 target 信息。
let _lastErrKey = ''
let _lastErrTime = 0
window.addEventListener(
'error',
(e: Event) => {
const target = e.target as HTMLElement | null
if (!target || typeof target.tagName !== 'string') return
const tag = target.tagName.toLowerCase()
if (tag !== 'script' && tag !== 'link') return
// 元素已被移除(Vue 卸载/重试后)则忽略
if (!target.isConnected) return
const src =
(target as HTMLScriptElement).src ||
(target as HTMLLinkElement).href ||
'页面资源'
const now = Date.now()
if (src === _lastErrKey && now - _lastErrTime < 3000) return
_lastErrKey = src
_lastErrTime = now
ElMessage.error(`资源加载失败(${tag === 'script' ? 'JS' : 'CSS'}):${src.split('/').pop()},请检查网络后刷新重试`)
},
true, // 捕获阶段:资源错误不会冒泡,必须捕获阶段监听
)
const app = createApp(App)
// 注册 Element Plus 图标为全局组件
+126 -3
View File
@@ -110,6 +110,18 @@ export const staticRoutes: RouteRecordRaw[] = [
component: () => import('@/views/company/Permission.vue'),
meta: { title: '权限管理', requiresAuth: true },
},
{
path: 'hr/position',
name: 'HrPosition',
component: () => import('@/views/hr/Position.vue'),
meta: { title: '职务管理', icon: 'Briefcase', requiresAuth: true, menuGuarded: true },
},
{
path: 'hr/position-category',
name: 'HrPositionCategory',
component: () => import('@/views/hr/PositionCategory.vue'),
meta: { title: '通用部门', icon: 'OfficeBuilding', requiresAuth: true, menuGuarded: true },
},
// === 阶段 3:详情页 ===
{
path: 'system/user-detail',
@@ -160,11 +172,10 @@ export const staticRoutes: RouteRecordRaw[] = [
component: () => import('@/views/company/RoleDetail.vue'),
meta: { title: '公司角色详情', requiresAuth: true },
},
// 员工详情已合并到系统用户详情(/system/user-detail),旧路径重定向保持链接可用
{
path: 'company/employee-detail',
name: 'CompanyEmployeeDetail',
component: () => import('@/views/company/EmployeeDetail.vue'),
meta: { title: '员工详情', requiresAuth: true },
redirect: (to) => ({ path: '/system/user-detail', query: to.query }),
},
// === 阶段 5:业务功能页 ===
{
@@ -367,6 +378,105 @@ export const staticRoutes: RouteRecordRaw[] = [
component: () => import('@/views/LegacyPage.vue'),
meta: { title: '储值查看', requiresAuth: true, menuGuarded: true, legacySrc: '/pages/private/CreateE/StoreValue.html' },
},
// === 鼎算财务(DingCalc Finance):Vue 页面(凭证/报表定制,其余通用 CRUD) ===
{
path: 'finance/voucher',
name: 'FinanceVoucher',
component: () => import('@/views/finance/Voucher.vue'),
meta: { title: '凭证管理', icon: 'Tickets', requiresAuth: true, menuGuarded: true },
},
{
path: 'finance/cashier',
name: 'FinanceCashier',
component: () => import('@/views/finance/Cashier.vue'),
meta: { title: '出纳管理', icon: 'Coin', requiresAuth: true, menuGuarded: true },
},
{
path: 'finance/ledger',
name: 'FinanceLedger',
component: () => import('@/views/finance/Ledger.vue'),
meta: { title: '会计账簿', icon: 'Notebook', requiresAuth: true, menuGuarded: true },
},
{
path: 'finance/report',
name: 'FinanceReport',
component: () => import('@/views/finance/Report.vue'),
meta: { title: '财务报表', icon: 'DataAnalysis', requiresAuth: true, menuGuarded: true },
},
{
path: 'finance/reimburse',
name: 'FinanceReimburse',
component: () => import('@/views/finance/Reimburse.vue'),
meta: { title: '费用报销', icon: 'Money', requiresAuth: true, menuGuarded: true },
},
{
path: 'finance/arap',
name: 'FinanceArap',
component: () => import('@/views/finance/Arap.vue'),
meta: { title: '应收应付', icon: 'Refresh', requiresAuth: true, menuGuarded: true },
},
{
path: 'finance/budget',
name: 'FinanceBudget',
component: () => import('@/views/finance/Budget.vue'),
meta: { title: '预算管理', icon: 'PieChart', requiresAuth: true, menuGuarded: true },
},
// === eHRM 人力资源管理扩展(档案/考核/绩效) ===
{
path: 'ehrm/profile',
name: 'EhrmProfile',
component: () => import('@/views/ehrm/Profile.vue'),
meta: { title: '档案管理', icon: 'Document', requiresAuth: true, menuGuarded: true },
},
{
path: 'ehrm/assessment',
name: 'EhrmAssessment',
component: () => import('@/views/ehrm/Assessment.vue'),
meta: { title: '考核管理', icon: 'Aim', requiresAuth: true, menuGuarded: true },
},
{
path: 'ehrm/performance',
name: 'EhrmPerformance',
component: () => import('@/views/ehrm/Performance.vue'),
meta: { title: '绩效管理', icon: 'TrendCharts', requiresAuth: true, menuGuarded: true },
},
// === OA 办公自动化流程(审批/请假/出差/会议/公告/汇报) ===
{
path: 'oa/approval',
name: 'OaApproval',
component: () => import('@/views/oa/Approval.vue'),
meta: { title: '审批', icon: 'Stamp', requiresAuth: true, menuGuarded: true },
},
{
path: 'oa/leave',
name: 'OaLeave',
component: () => import('@/views/oa/Leave.vue'),
meta: { title: '请假申请', icon: 'Calendar', requiresAuth: true, menuGuarded: true },
},
{
path: 'oa/travel',
name: 'OaTravel',
component: () => import('@/views/oa/Travel.vue'),
meta: { title: '出差申请', icon: 'Promotion', requiresAuth: true, menuGuarded: true },
},
{
path: 'oa/meeting',
name: 'OaMeeting',
component: () => import('@/views/oa/Meeting.vue'),
meta: { title: '会议管理', icon: 'OfficeBuilding', requiresAuth: true, menuGuarded: true },
},
{
path: 'oa/announcement',
name: 'OaAnnouncement',
component: () => import('@/views/oa/Announcement.vue'),
meta: { title: '公告通知', icon: 'Bell', requiresAuth: true, menuGuarded: true },
},
{
path: 'oa/report',
name: 'OaReport',
component: () => import('@/views/oa/Report.vue'),
meta: { title: '工作汇报', icon: 'Document', requiresAuth: true, menuGuarded: true },
},
// === SKU(移动端 C 端):全 Mock 页面已从生产路由移除 ===
// Sku.vue 仍保留源码(1870 行 Mock 数据 + TODO),待决策是否拆分为独立移动端工程后再接入
// === 兜底:未匹配路由在 DefaultLayout 内显示 404(保留 menu/nav/tab) ===
@@ -397,6 +507,19 @@ const router = createRouter({
/** 标记本次导航是否为首次打开的新 tab(用于 afterEach 区分 loading 策略) */
let justOpenedNewTab = false
/** 路由导航错误兜底:懒加载 chunk 404/502 时贴出报错提示(避免静默失败白屏) */
let _lastRouteErr = ''
let _lastRouteErrTime = 0
router.onError((error) => {
const msg = error instanceof Error ? error.message : String(error)
const isChunk = /chunk|loading chunk|import\(\)|dynamic import/i.test(msg)
const now = Date.now()
if (msg === _lastRouteErr && now - _lastRouteErrTime < 5000) return
_lastRouteErr = msg
_lastRouteErrTime = now
ElMessage.error(isChunk ? '页面组件加载失败(网络异常或资源不存在),请刷新重试' : `页面加载失败:${msg}`)
})
/** 全局前置守卫:进度条启动、立即开 tab、登录态校验、动态路由注入 */
router.beforeEach(async (to, _from, next) => {
const tabsStore = useTabsStore()
+5
View File
@@ -188,6 +188,11 @@ html.dark {
font-weight: 400;
}
/* dark 主题:disabled 按钮文字用 72% 白(半透明白 50% 过淡、标准禁用灰偏黑) */
html.dark .el-button.is-disabled {
--el-button-disabled-text-color: rgba(255, 255, 255, 0.72);
}
/* 文本按钮(表格操作列):主色、更紧凑 */
.el-button.is-link {
padding: 2px 4px;
+2
View File
@@ -226,6 +226,8 @@ export interface ColumnSchema {
// === 实体选择器 ===
/** type=entityPicker 时的配置 */
entityPicker?: EntityPickerConfig
/** entityPicker 多值(multiValue):列表值/回显为逗号分隔的多个实体,表单多选(如身兼数职) */
multiValue?: boolean
// === 分隔/HTML ===
/** type=divider 时的分隔文本 */
+1 -1
View File
@@ -50,7 +50,7 @@ async function handleLogin(): Promise<void> {
<div class="brand-panel">
<div class="brand-inner">
<div class="brand-badge">
<img src="/favicon.svg" alt="logo" class="brand-logo" />
<img src="/favicon.ico" alt="logo" class="brand-logo" />
<span class="brand-name">GERP</span>
</div>
<h1 class="brand-headline">企业级业务<br />统一管理平台</h1>
+409 -33
View File
@@ -8,13 +8,118 @@
* - UserGender:-1=保密,1=男,0=女
* - UserAccountLicense:0=禁用,1=启用
* - UserPositionStatus:1=在职,0=停职
*
* 操作能力:修改头像(/Modify/ModifyELTUserAvatar)、修改名字(/API/User/ModifyPersonalConfig)、
* 修改密码(/API/User/ModifyPassword)。
*/
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useUserStore } from '@/stores/user'
import { getAvatarUrl } from '@/api/auth'
import { getAvatarUrl, uploadAvatar, modifyPersonalConfig, modifyPassword } from '@/api/auth'
const userStore = useUserStore()
const avatarUrl = getAvatarUrl()
/** 头像版本号:上传成功后 +1,附加到 URL 时间戳强制刷新浏览器缓存 */
const avatarVersion = ref(0)
const avatarUrl = computed(() => getAvatarUrl(avatarVersion.value))
const avatarUploading = ref(false)
const fileInput = ref<HTMLInputElement>()
/** 触发文件选择 */
function pickAvatar(): void {
fileInput.value?.click()
}
/** 头像文件选择后立即上传 */
async function handleAvatarChange(e: Event): Promise<void> {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
if (avatarUploading.value) return
avatarUploading.value = true
try {
await uploadAvatar(file)
avatarVersion.value++
ElMessage.success('头像修改成功')
} catch {
// 错误提示由请求拦截器统一弹出
} finally {
avatarUploading.value = false
}
}
// === 修改名字 ===
const nameDialogVisible = ref(false)
const nameForm = ref({ UserName: '' })
const nameSubmitting = ref(false)
function openNameDialog(): void {
nameForm.value = { UserName: userStore.userInfo?.UserName || '' }
nameDialogVisible.value = true
}
async function submitName(): Promise<void> {
const name = nameForm.value.UserName.trim()
if (!name) {
ElMessage.warning('名字不能为空')
return
}
if (nameSubmitting.value) return
nameSubmitting.value = true
try {
await modifyPersonalConfig({ UserName: name })
ElMessage.success('名字修改成功')
nameDialogVisible.value = false
await userStore.fetchUserInfo()
} catch {
// 错误提示由拦截器统一弹出
} finally {
nameSubmitting.value = false
}
}
// === 修改密码 ===
const pwdDialogVisible = ref(false)
const pwdForm = ref({ UserOldPassword: '', UserNewPassword: '', UserNewPassword2: '' })
const pwdSubmitting = ref(false)
function openPwdDialog(): void {
pwdForm.value = { UserOldPassword: '', UserNewPassword: '', UserNewPassword2: '' }
pwdDialogVisible.value = true
}
async function submitPassword(): Promise<void> {
const { UserOldPassword, UserNewPassword, UserNewPassword2 } = pwdForm.value
if (!UserOldPassword) {
ElMessage.warning('请输入旧密码')
return
}
if (UserNewPassword.length < 6) {
ElMessage.warning('新密码长度至少 6 位')
return
}
if (UserNewPassword !== UserNewPassword2) {
ElMessage.warning('两次输入的新密码不一致')
return
}
if (pwdSubmitting.value) return
pwdSubmitting.value = true
try {
await modifyPassword({ UserOldPassword, UserNewPassword })
ElMessage.success('密码修改成功')
pwdDialogVisible.value = false
} catch {
// 错误提示由拦截器统一弹出
} finally {
pwdSubmitting.value = false
}
}
// === 展示字段 ===
/** 显示名:DisplayName > UserName */
const displayName = computed(() => userStore.userInfo?.DisplayName || userStore.userInfo?.UserName || '-')
/** 邮箱:后端返回 UserEmail(兼容旧字段 Email) */
const email = computed(() => userStore.userInfo?.UserEmail || userStore.userInfo?.Email || '-')
@@ -51,39 +156,157 @@ const positionStatus = computed(() => {
if (v === 0) return { text: '停职', type: 'danger' as const }
return { text: '-', type: 'info' as const }
})
/** 个人签名(空时显示占位) */
const signature = computed(() => userStore.userInfo?.UserPersonalSignature?.trim() || '这个人很懒,还没有签名~')
/** 角色列表(数组) */
const roleList = computed(() => userStore.userInfo?.Roles?.length ? userStore.userInfo.Roles : [])
</script>
<template>
<div class="profile-page">
<el-card shadow="never">
<template #header>
<span>个人中心</span>
</template>
<el-card shadow="never" class="profile-card">
<template #header>
<div class="profile-card-header">
<span>个人中心</span>
<el-tag v-if="userStore.isAdmin" type="danger" size="small" effect="light">管理员</el-tag>
<el-tag v-else type="info" size="small" effect="light">普通用户</el-tag>
</div>
</template>
<div class="profile">
<img :src="avatarUrl" alt="头像" class="avatar" />
<el-descriptions :column="1" border>
<el-descriptions-item label="用户名">{{ userStore.userInfo?.UserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ email }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ userStore.userInfo?.UserTele || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText }}</el-descriptions-item>
<el-descriptions-item label="邮箱验证">
<el-tag :type="emailVerify.type" size="small">{{ emailVerify.text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="accountStatus.type" size="small">{{ accountStatus.text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="职位状态">
<el-tag :type="positionStatus.type" size="small">{{ positionStatus.text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="当前角色">{{ userStore.currentRole || '-' }}</el-descriptions-item>
<el-descriptions-item label="是否管理员">
<el-tag :type="userStore.isAdmin ? 'danger' : 'info'">{{ userStore.isAdmin ? '是' : '否' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ userStore.userInfo?.UserCreateTime || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-card>
<div class="profile">
<!-- 左侧:头像卡片 -->
<div class="profile-side">
<div class="avatar-wrap" :class="{ uploading: avatarUploading }" @click="pickAvatar">
<img :src="avatarUrl" alt="头像" class="avatar" />
<div class="avatar-mask" v-loading="avatarUploading">
<el-icon v-if="!avatarUploading" :size="20"><Camera /></el-icon>
<span v-if="!avatarUploading">更换头像</span>
</div>
</div>
<div class="profile-name">{{ displayName }}</div>
<div class="profile-role">
<el-tag size="small" effect="plain">{{ userStore.currentRole || '无当前角色' }}</el-tag>
</div>
<div class="profile-signature">{{ signature }}</div>
<!-- 操作按钮 -->
<div class="profile-actions">
<el-button size="small" @click="openNameDialog">
<el-icon><EditPen /></el-icon>修改名字
</el-button>
<el-button size="small" @click="openPwdDialog">
<el-icon><Lock /></el-icon>修改密码
</el-button>
</div>
<!-- 隐藏的文件选择器(点击头像触发) -->
<input
ref="fileInput"
type="file"
accept="image/png,image/jpeg,image/bmp"
class="avatar-file-input"
@change="handleAvatarChange"
/>
</div>
<!-- 右侧:信息分组 -->
<div class="profile-main">
<!-- 基本信息 -->
<el-descriptions title="基本信息" :column="2" border class="profile-desc">
<el-descriptions-item label="用户名">{{ userStore.userInfo?.UserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="显示名">{{ userStore.userInfo?.DisplayName || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ email }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ userStore.userInfo?.UserTele || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText }}</el-descriptions-item>
<el-descriptions-item label="个人签名">{{ userStore.userInfo?.UserPersonalSignature || '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 账号状态 -->
<el-descriptions title="账号状态" :column="2" border class="profile-desc">
<el-descriptions-item label="邮箱验证">
<el-tag :type="emailVerify.type" size="small">{{ emailVerify.text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="accountStatus.type" size="small">{{ accountStatus.text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="职位状态">
<el-tag :type="positionStatus.type" size="small">{{ positionStatus.text }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否管理员">
<el-tag :type="userStore.isAdmin ? 'danger' : 'info'" size="small">
{{ userStore.isAdmin ? '是' : '否' }}
</el-tag>
</el-descriptions-item>
</el-descriptions>
<!-- 账号信息 -->
<el-descriptions title="账号信息" :column="2" border class="profile-desc">
<el-descriptions-item label="当前角色">{{ userStore.currentRole || '-' }}</el-descriptions-item>
<el-descriptions-item label="用户 UUID">
<span class="uuid-text">{{ userStore.userInfo?.UserUUID || '-' }}</span>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ userStore.userInfo?.UserCreateTime || '-' }}</el-descriptions-item>
<el-descriptions-item label="最后登录">{{ userStore.userInfo?.UserLastLoginDate || '-' }}</el-descriptions-item>
<el-descriptions-item label="全部角色" :span="2">
<template v-if="roleList.length">
<el-tag
v-for="(r, i) in roleList"
:key="i"
size="small"
class="role-tag"
effect="plain"
>{{ r }}</el-tag>
</template>
<span v-else>-</span>
</el-descriptions-item>
</el-descriptions>
</div>
</div>
</el-card>
<!-- 修改名字对话框 -->
<el-dialog v-model="nameDialogVisible" title="修改名字" width="420px" :close-on-click-modal="false">
<el-form label-width="80px" @submit.prevent>
<el-form-item label="新名字">
<el-input
v-model="nameForm.UserName"
placeholder="请输入新的名字"
maxlength="32"
show-word-limit
@keyup.enter="submitName"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="nameDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="nameSubmitting" @click="submitName">保存</el-button>
</template>
</el-dialog>
<!-- 修改密码对话框 -->
<el-dialog v-model="pwdDialogVisible" title="修改密码" width="420px" :close-on-click-modal="false">
<el-form label-width="80px" @submit.prevent>
<el-form-item label="旧密码">
<el-input v-model="pwdForm.UserOldPassword" type="password" show-password placeholder="请输入旧密码" />
</el-form-item>
<el-form-item label="新密码">
<el-input v-model="pwdForm.UserNewPassword" type="password" show-password placeholder="至少 6 位" />
</el-form-item>
<el-form-item label="确认新密码">
<el-input
v-model="pwdForm.UserNewPassword2"
type="password"
show-password
placeholder="再次输入新密码"
@keyup.enter="submitPassword"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="pwdDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="pwdSubmitting" @click="submitPassword">确认修改</el-button>
</template>
</el-dialog>
</div>
</template>
@@ -94,15 +317,168 @@ const positionStatus = computed(() => {
box-sizing: border-box;
overflow: auto;
}
.profile-card-header {
display: flex;
align-items: center;
gap: 8px;
}
.profile {
display: flex;
gap: 32px;
gap: 24px;
align-items: flex-start;
}
.avatar {
/* 左侧头像卡片 */
.profile-side {
width: 200px;
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 16px;
background: var(--el-fill-color-light);
border-radius: 12px;
text-align: center;
}
/* 头像可点击更换,hover 显示遮罩 */
.avatar-wrap {
position: relative;
width: 120px;
height: 120px;
border-radius: 50%;
cursor: pointer;
overflow: hidden;
border: 3px solid var(--el-bg-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
display: block;
}
.avatar-mask {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 12px;
opacity: 0;
transition: opacity 0.2s;
border-radius: 50%;
}
.avatar-wrap:hover .avatar-mask,
.avatar-wrap.uploading .avatar-mask {
opacity: 1;
}
.avatar-file-input {
display: none;
}
.profile-name {
margin-top: 14px;
font-size: 18px;
font-weight: 600;
color: var(--el-text-color-primary);
word-break: break-all;
}
.profile-role {
margin-top: 8px;
}
.profile-signature {
margin-top: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.6;
word-break: break-all;
}
.profile-actions {
margin-top: 16px;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
/* 右侧信息区 */
.profile-main {
flex: 1;
min-width: 0;
}
.profile-desc {
margin-bottom: 16px;
:deep(.el-descriptions__title) {
font-weight: 600;
}
}
.role-tag {
margin: 2px 6px 2px 0;
}
.uuid-text {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
word-break: break-all;
}
/* 移动端:头像卡片与信息纵向堆叠 */
@media (max-width: 768px) {
.profile {
flex-direction: column;
}
.profile-side {
width: 100%;
}
.profile-desc {
:deep(.el-descriptions__body) {
display: block;
}
:deep(.el-descriptions__table) {
width: 100%;
}
}
}
</style>
<!--
非 scoped 样式:el-button 是子组件根元素,scoped 的 :deep 编译为
`.profile-actions [data-v] .el-button`(data-v 在中间),真实 DOM 中按钮自身带 data-v,
选择器要求 data-v 元素是 .el-button 的祖先,永不匹配 → 需全局样式以 .profile-page 前缀限定。
作用:纵向按钮等宽居中 + 覆盖 EP 默认 `.el-button + .el-button { margin-left: 12px }`。
-->
<style>
.profile-page .profile-actions .el-button {
width: 100%;
margin: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.profile-page .profile-actions .el-button + .el-button {
margin-left: 0;
}
.profile-page .profile-actions .el-button .el-icon {
margin-right: 4px;
}
</style>
+479 -2
View File
@@ -12,7 +12,8 @@
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { appApi, type AppConfigItem, type CompanyScopeItem } from '@/api/app'
import { appApi, type AppConfigItem, type CompanyScopeItem, type VirtualMappingItem, type PositionTemplateDiffItem, type PositionTemplateRemoveItem } from '@/api/app'
import type { EntityPickerConfig } from '@/types/api'
import { setPageTitle } from '@/utils/serverScope'
const route = useRoute()
@@ -152,11 +153,228 @@ async function onCopy(): Promise<void> {
}
}
watch(currentCompany, loadConfig)
// 切换范围(租户默认/公司)→ 配置与虚拟职务绑定均按新范围重新加载
watch(currentCompany, () => {
loadConfig()
loadVirtualMapping()
})
// ==================== 虚拟职务 ↔ 真实职务映射 ====================
const vpMappings = ref<VirtualMappingItem[]>([])
const vpLoading = ref(false)
const vpSaving = ref(false)
const vpDirty = ref(false)
/** 真实职务实体选择配置(与员工编辑职务列同款:通用部门树分组 + 多选) */
const positionPickerConfig: EntityPickerConfig = {
api: '/API/FormAPI/PositionSearch',
labelField: 'PositionName',
valueField: 'PositionUUID',
placeholder: '输入职务名搜索...',
treeGroup: {
groupField: 'CategoryUUID',
treeField: 'tree',
ungroupedTitle: '未归属通用部门',
},
}
async function loadVirtualMapping(): Promise<void> {
if (!AppUUID.value) return
vpLoading.value = true
try {
const r = await appApi.getVirtualMapping(AppUUID.value, currentCompany.value || undefined)
vpMappings.value = r.VirtualPositions || []
vpDirty.value = false
} catch { /* 拦截器已提示 */ } finally {
vpLoading.value = false
}
}
/** 选择变化 → 标记脏(显示保存按钮状态) */
function onVpChange(): void {
vpDirty.value = true
}
async function saveVirtualMapping(): Promise<void> {
vpSaving.value = true
try {
const r = await appApi.setVirtualMapping(
AppUUID.value,
vpMappings.value.map((vp) => ({
AppVirtualPositionUUID: vp.AppVirtualPositionUUID,
PositionUUIDs: vp.PositionUUIDs,
})),
currentCompany.value || undefined,
)
ElMessage.success(r.msg || '虚拟职务绑定已保存')
vpDirty.value = false
await loadVirtualMapping()
} catch { /* 拦截器已提示 */ } finally {
vpSaving.value = false
}
}
/** 一键绑定:按名称把虚拟职务自动匹配到真实职务(前提:通用部门与职务模板已同步) */
const vpAutoBinding = ref(false)
async function autoBindVirtualPositions(): Promise<void> {
vpAutoBinding.value = true
try {
const r = await appApi.autoBindVirtualPositions(AppUUID.value, currentCompany.value || undefined)
ElMessage.success(r.msg || '一键绑定完成')
await loadVirtualMapping()
} catch { /* 拦截器已提示 */ } finally {
vpAutoBinding.value = false
}
}
// ==================== 通用部门与职务模板同步 ====================
const ptDiff = ref<PositionTemplateDiffItem[]>([])
const ptDialogVisible = ref(false)
const ptLoading = ref(false)
const ptSyncing = ref(false)
/** 树节点(el-tree:部门 → 职务) */
interface PtTreeNode {
label: string
status: 'new' | 'exists'
children?: PtTreeNode[]
}
/** 应用声明模板全量 tree(默认展示:已有复用/待新增) */
const ptTreeData = computed<PtTreeNode[]>(() =>
ptDiff.value.map((c) => ({
label: c.CategoryName,
status: c.Status,
children: c.Positions.map((p) => ({
label: p.PositionName,
status: p.Status,
})),
})),
)
/** 仅待新增项 tree(同步确认弹窗:即将加入的部门与职务) */
const ptPendingTreeData = computed<PtTreeNode[]>(() =>
ptDiff.value
.filter((c) => c.Status === 'new' || c.Positions.some((p) => p.Status === 'new'))
.map((c) => ({
label: c.CategoryName,
status: c.Status,
children: c.Positions.filter((p) => p.Status === 'new').map((p) => ({
label: p.PositionName,
status: p.Status,
})),
})),
)
function statusText(s: string): string {
return s === 'new' ? '待新增' : '已有复用'
}
/** 加载应用模板当前状态 tree(页面默认展示) */
async function loadTemplateTree(): Promise<void> {
if (!AppUUID.value) return
try {
const r = await appApi.getPositionTemplateDiff(AppUUID.value)
ptDiff.value = r.Categories || []
} catch { /* 拦截器已提示 */ }
}
/** 打开同步确认:先拉取缺失项(按名称匹配,同名已有=复用) */
async function openSyncDialog(): Promise<void> {
if (!AppUUID.value) return
ptLoading.value = true
try {
const r = await appApi.getPositionTemplateDiff(AppUUID.value)
ptDiff.value = r.Categories || []
if (!r.NeedSync) {
ElMessage.info('该应用所需的通用部门与职务均已存在,无需同步')
return
}
ptDialogVisible.value = true
} catch { /* 拦截器已提示 */ } finally {
ptLoading.value = false
}
}
/** 确认同步:补齐应用声明的通用部门与职务到当前租户 */
async function onSyncTemplates(): Promise<void> {
if (!AppUUID.value) return
ptSyncing.value = true
try {
const r = await appApi.syncPositionTemplates(AppUUID.value)
ElMessage.success(r.msg || '同步完成')
ptDialogVisible.value = false
await loadVirtualMapping()
await loadTemplateTree()
} catch { /* 拦截器已提示 */ } finally {
ptSyncing.value = false
}
}
// ==================== 通用部门与职务模板反向移除 ====================
const ptRemoveDiff = ref<PositionTemplateRemoveItem[]>([])
const ptRemoveDialogVisible = ref(false)
const ptRemoveLoading = ref(false)
const ptRemoving = ref(false)
/** 移除树节点(el-tree:部门 → 职务,标注可移除性) */
interface PtRemoveNode {
label: string
removable: boolean
reason: string
children?: PtRemoveNode[]
}
const ptRemoveTreeData = computed<PtRemoveNode[]>(() =>
ptRemoveDiff.value.map((c) => ({
label: c.CategoryName,
removable: c.Removable,
reason: c.BlockedReason,
children: c.Positions.map((p) => ({
label: p.PositionName,
removable: p.Removable,
reason: p.BlockedReason,
})),
})),
)
/** 打开移除确认:拉取可移除项(必须所有员工均未持有这些职务才可移除) */
async function openRemoveDialog(): Promise<void> {
if (!AppUUID.value) return
ptRemoveLoading.value = true
try {
const r = await appApi.getPositionTemplateRemovePreview(AppUUID.value)
ptRemoveDiff.value = r.Categories || []
if (!r.AnyRemovable) {
ElMessage.info('没有可移除的通用部门与职务(已有项均被员工持有或不存在)')
return
}
ptRemoveDialogVisible.value = true
} catch { /* 拦截器已提示 */ } finally {
ptRemoveLoading.value = false
}
}
/** 确认移除:仅删除可移除项(员工持有的保留) */
async function onRemoveTemplates(): Promise<void> {
if (!AppUUID.value) return
ptRemoving.value = true
try {
const r = await appApi.removePositionTemplates(AppUUID.value)
ElMessage.success(r.msg || '移除完成')
ptRemoveDialogVisible.value = false
await loadVirtualMapping()
await loadTemplateTree()
} catch { /* 拦截器已提示 */ } finally {
ptRemoving.value = false
}
}
onMounted(async () => {
await initScope()
await loadConfig()
await loadVirtualMapping()
await loadTemplateTree()
})
</script>
@@ -231,6 +449,179 @@ onMounted(async () => {
</template>
</el-card>
<!-- 虚拟职务 ↔ 真实职务映射:拥有被绑定真实职务的用户看到对应虚拟职务声明的应用菜单 -->
<el-card v-if="AppUUID" shadow="never" class="cfg-card vp-card">
<template #header>
<div class="cfg-header">
<span class="cfg-title">虚拟职务绑定</span>
<span class="cfg-sub">把真实职务对应到应用虚拟职务(由应用清单声明可见菜单)· 绑定范围:{{ scopeLabel }}</span>
<div class="spacer" />
<el-button
size="small"
type="warning"
:loading="vpAutoBinding"
@click="autoBindVirtualPositions"
>
一键绑定
</el-button>
<el-button
size="small"
type="primary"
:loading="vpSaving"
:disabled="!vpDirty"
@click="saveVirtualMapping"
>
保存绑定
</el-button>
</div>
</template>
<div v-loading="vpLoading" class="vp-body">
<div v-for="vp in vpMappings" :key="vp.AppVirtualPositionUUID" class="vp-row">
<div class="vp-info">
<div class="vp-name-row">
<span class="vp-name">{{ vp.Name }}</span>
<span class="vp-code">{{ vp.Code }}</span>
</div>
<div class="vp-menus">
<el-tag
v-for="m in vp.MenuNames"
:key="m"
size="small"
type="info"
effect="plain"
>{{ m }}</el-tag>
<span v-if="!vp.MenuNames.length" class="vp-menus-empty">未声明可见菜单</span>
</div>
</div>
<MultiEntityPicker
v-model="vp.PositionUUIDs"
:config="positionPickerConfig"
:label-text="vp.PositionNames ? vp.PositionNames.join('、') : ''"
placeholder="绑定真实职务(可多选)"
@update:modelValue="onVpChange"
/>
</div>
<el-empty
v-if="!vpLoading && !vpMappings.length"
description="该应用未声明虚拟职务"
:image-size="60"
/>
</div>
</el-card>
<!-- 通用部门与职务模板同步:应用声明的通用部门/职务按名称补齐到当前租户(已有复用) -->
<el-card v-if="AppUUID" shadow="never" class="cfg-card pt-card">
<template #header>
<div class="cfg-header">
<el-icon><OfficeBuilding /></el-icon>
<span class="cfg-title">通用部门与职务模板</span>
<span class="cfg-sub">应用声明的通用部门与职务(按名称匹配,同名已有则直接复用)</span>
<div class="spacer" />
<el-button size="small" type="danger" :loading="ptRemoveLoading" plain @click="openRemoveDialog">
移除通用部门与职务
</el-button>
<el-button size="small" type="primary" :loading="ptLoading" plain @click="openSyncDialog">
同步通用部门与职务
</el-button>
</div>
</template>
<div class="pt-tip">
应用声明的通用部门与职务(按名称匹配,同名已有则直接复用)。点击"同步"补齐缺失项;点击"移除"仅删除所有员工均未持有的职务及已清空的通用部门。
</div>
<el-tree
:data="ptTreeData"
:props="{ label: 'label', children: 'children' }"
default-expand-all
class="pt-tree pt-tree-static"
v-loading="ptLoading"
>
<template #default="{ data }">
<span class="pt-node">
<span class="pt-node-label">{{ data.label }}</span>
<el-tag
:type="data.status === 'new' ? 'danger' : 'success'"
size="small"
effect="plain"
>{{ statusText(data.status) }}</el-tag>
</span>
</template>
</el-tree>
<el-empty
v-if="!ptLoading && !ptTreeData.length"
description="该应用未声明通用部门与职务模板"
:image-size="60"
/>
</el-card>
<!-- 通用部门与职务同步确认弹窗:tree 列出待新增项 -->
<el-dialog
v-model="ptDialogVisible"
title="同步确认"
width="480px"
:close-on-click-modal="false"
>
<div class="pt-confirm-title">即将将下列的通用部门和职务加入到本租户,是否允许?</div>
<el-tree
:data="ptPendingTreeData"
:props="{ label: 'label', children: 'children' }"
default-expand-all
class="pt-tree"
>
<template #default="{ data }">
<span class="pt-node">
<span class="pt-node-label">{{ data.label }}</span>
<el-tag
:type="data.status === 'new' ? 'danger' : 'success'"
size="small"
effect="plain"
>{{ statusText(data.status) }}</el-tag>
</span>
</template>
</el-tree>
<template #footer>
<el-button @click="ptDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="ptSyncing" @click="onSyncTemplates">允许同步</el-button>
</template>
</el-dialog>
<!-- 通用部门与职务反向移除确认弹窗:tree 展示可移除/被持有项 -->
<el-dialog
v-model="ptRemoveDialogVisible"
title="移除确认"
width="500px"
:close-on-click-modal="false"
>
<div class="pt-confirm-title">将移除下列应用声明的通用部门与职务(仅删除<b>所有员工均未持有</b>的职务及已清空的部门):</div>
<el-tree
:data="ptRemoveTreeData"
:props="{ label: 'label', children: 'children' }"
default-expand-all
class="pt-tree"
>
<template #default="{ data }">
<span class="pt-node">
<span class="pt-node-label">{{ data.label }}</span>
<el-tag
v-if="data.removable"
type="success"
size="small"
effect="plain"
>可移除</el-tag>
<el-tag
v-else
type="danger"
size="small"
effect="plain"
>{{ data.reason || '不可移除' }}</el-tag>
</span>
</template>
</el-tree>
<template #footer>
<el-button @click="ptRemoveDialogVisible = false">取消</el-button>
<el-button type="danger" :loading="ptRemoving" @click="onRemoveTemplates">确认移除</el-button>
</template>
</el-dialog>
<!-- 复制配置到公司弹窗 -->
<el-dialog
v-model="copyDialogVisible"
@@ -270,6 +661,92 @@ onMounted(async () => {
.cfg-card {
border-radius: 8px;
}
.spacer {
flex: 1;
}
.vp-card {
margin-top: 16px;
}
.pt-card {
margin-top: 16px;
}
.pt-tip {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.pt-tree-static {
margin-top: 10px;
}
.pt-confirm-title {
font-size: 13px;
color: var(--el-text-color-primary);
margin-bottom: 12px;
line-height: 1.6;
}
.pt-tree {
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
padding: 8px;
}
.pt-node {
display: inline-flex;
align-items: center;
gap: 8px;
}
.pt-node-label {
font-size: 13px;
}
.vp-body {
min-height: 60px;
display: flex;
flex-direction: column;
gap: 10px;
}
.vp-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.vp-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.vp-name-row {
display: flex;
align-items: center;
gap: 8px;
}
.vp-name {
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.vp-code {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
.vp-menus {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
.vp-menus-empty {
font-size: 12px;
color: var(--el-text-color-placeholder);
}
}
.vp-row :deep(.multi-entity-picker) {
width: 400px;
flex-shrink: 0;
}
.cfg-header {
display: flex;
align-items: center;
+138 -4
View File
@@ -9,11 +9,12 @@
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Grid } from '@element-plus/icons-vue'
import { Grid, Link } from '@element-plus/icons-vue'
import { appApi, type AppItem, type CompanyScopeItem, type RoleScopeItem } from '@/api/app'
import { getAppModule, appOpenTarget } from '@/apps'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
import CrudTable from '@/components/CrudTable.vue'
setPageTitle('应用市场')
@@ -235,6 +236,11 @@ function marketActionDesc(a: AppItem): string {
return ''
}
/** 卡片状态文本(独立一行展示,避免与操作按钮同行折叠) */
function statusRowText(a: AppItem): string {
return marketActionDesc(a) || (a.ApplyStatus === 2 ? '已授权' : a.ApplyStatus === 3 ? '已拒绝' : '')
}
/** 管理范围内是否存在安装(租户/本公司/管理范围内任意公司)→ 决定显示"管理"还是"安装" */
function hasManageableInstall(a: AppItem): boolean {
if (a.TenantInstalled || a.CompanyInstalled) return true
@@ -269,6 +275,38 @@ function openConfig(a: AppItem): void {
router.push({ path: '/app/config', query: { AppUUID: a.AppUUID, AppName: a.AppName } })
}
// ==================== 应用-职务绑定(管理员) ====================
const bindDialogVisible = ref(false)
/** 绑定弹窗 Tab:direct=直接职务 / virtual=虚拟职务 */
const bindTab = ref<'direct' | 'virtual'>('direct')
/** 管理员:切换严格职务可见模式(1=无绑定时全员不可见,必须绑定职务;0=宽松) */
async function toggleStrictVisible(a: AppItem): Promise<void> {
const next = a.StrictPositionVisible === 1 ? 0 : 1
const actionText = next === 1 ? '开启' : '关闭'
const tip = next === 1
? '开启后,未绑定职务的应用将全员不可见;必须先到「职务绑定」中为该应用绑定可访问职务。'
: '关闭后,未绑定职务的应用将恢复全员可见(宽松模式)。'
try {
await ElMessageBox.confirm(
`确定${actionText}「${a.AppName}」的严格职务可见模式吗?\n${tip}`,
'严格职务可见',
{
type: next === 1 ? 'warning' : 'info',
confirmButtonText: actionText,
cancelButtonText: '取消',
},
)
} catch {
return
}
try {
const r = await appApi.setStrictVisible(a.AppUUID, next)
ElMessage.success(r.msg || `已${actionText}`)
await loadMarket()
} catch { /* 拦截器已提示 */ }
}
onMounted(loadMarket)
</script>
@@ -280,6 +318,10 @@ onMounted(loadMarket)
<el-icon class="header-icon"><Grid /></el-icon>
<span>应用市场</span>
<span class="header-sub">企业功能 · 个人应用 · 企业授权</span>
<div class="spacer" />
<el-button v-if="isAdmin" size="small" type="warning" plain @click="bindDialogVisible = true">
<el-icon><Link /></el-icon>职务绑定
</el-button>
</div>
</template>
@@ -294,6 +336,9 @@ onMounted(loadMarket)
<div class="app-code">{{ a.AppCode }}</div>
</div>
<el-tag :type="typeLabel(a.AppType).type" size="small">{{ typeLabel(a.AppType).text }}</el-tag>
<!-- 强制职务绑定(应用清单声明,硬性要求)→ 强制职务;管理员开关开启 → 严格可见 -->
<el-tag v-if="a.RequirePositionBinding === 1" type="danger" size="small" effect="dark">强制职务</el-tag>
<el-tag v-else-if="a.StrictPositionVisible === 1" type="warning" size="small" effect="dark">严格可见</el-tag>
</div>
<div class="app-desc">{{ a.AppDesc || '暂无描述' }}</div>
<!-- 管理员视角:标记管理范围内已装该应用的公司(含非本公司) -->
@@ -308,11 +353,36 @@ onMounted(loadMarket)
class="app-installed-tag"
>{{ n }}</el-tag>
</div>
<!-- 状态文本独立一行,避免与操作按钮同行折叠 -->
<div v-if="statusRowText(a)" class="app-status-row">
<span
class="app-status"
:class="{ ok: a.ApplyStatus === 2, err: a.ApplyStatus === 3 }"
>{{ statusRowText(a) }}</span>
</div>
<div class="app-foot">
<span v-if="marketActionDesc(a)" class="app-status">{{ marketActionDesc(a) }}</span>
<span v-else-if="a.ApplyStatus === 2" class="app-status ok">已授权</span>
<span v-else-if="a.ApplyStatus === 3" class="app-status err">已拒绝</span>
<div class="spacer" />
<!-- 严格职务可见开关(管理员):1=无绑定时全员不可见,必须绑定职务。
强制职务(RequirePositionBinding=1)为应用清单声明,禁用开关 -->
<el-tooltip
v-if="isAdmin"
:content="a.RequirePositionBinding === 1
? '该应用由清单声明强制要求绑定职务,不可关闭'
: a.StrictPositionVisible === 1
? '关闭严格后,未绑定职务的应用恢复全员可见'
: '开启严格后,未绑定职务的应用全员不可见'"
placement="top"
>
<el-button
size="small"
:type="a.StrictPositionVisible === 1 ? 'warning' : 'default'"
:plain="a.StrictPositionVisible === 1"
:disabled="a.RequirePositionBinding === 1"
@click="toggleStrictVisible(a)"
>
{{ a.RequirePositionBinding === 1 ? '强制职务' : (a.StrictPositionVisible === 1 ? '关闭严格' : '开启严格') }}
</el-button>
</el-tooltip>
<!-- 打开应用(已安装/已启用时可用) -->
<el-button v-if="isAppUsable(a)" size="small" type="primary" plain @click="openApp(a)">打开</el-button>
<!-- 企业功能:管理员安装/管理(公司级);普通用户提示 -->
@@ -485,6 +555,49 @@ onMounted(loadMarket)
<el-button type="primary" :loading="manageDialogLoading" @click="saveCompanyManage">保存公司状态</el-button>
</template>
</el-dialog>
<!-- 应用-职务绑定弹窗:直接职务绑定 / 虚拟职务绑定 -->
<el-dialog v-model="bindDialogVisible" title="应用-职务绑定" width="900px" destroy-on-close append-to-body>
<p class="gs-bind-tip">
绑定后,仅拥有绑定职务的用户可见/使用该应用(未绑定的应用默认全员可见;
标记"强制职务/严格可见"的应用在无绑定时全员不可见)。
</p>
<el-tabs v-model="bindTab" type="border-card">
<!-- 直接职务:每行 = 一个应用 + 一个可访问职务,可添加多条 -->
<el-tab-pane label="直接职务" name="direct">
<CrudTable
table="AppPosition"
title="绑定列表"
:max-height="360"
pageable
:default-page-size="50"
/>
</el-tab-pane>
<!-- 虚拟职务:应用清单声明虚拟职务(含可见菜单),安装后绑定实际职务;
拥有被绑定实际职务的用户经此获得虚拟职务声明的应用菜单可见性 -->
<el-tab-pane label="虚拟职务" name="virtual">
<p class="gs-bind-tip">
虚拟职务由应用清单声明(每个虚拟职务声明可见的应用菜单)。此处把系统实际职务绑定到虚拟职务:
拥有被绑定实际职务的用户,将看到对应虚拟职务声明的应用菜单。
</p>
<CrudTable
table="AppVirtualPosition"
title="虚拟职务定义(清单同步)"
:max-height="300"
pageable
:default-page-size="50"
/>
<div class="gs-vp-sep" />
<CrudTable
table="AppVirtualBinding"
title="虚拟职务绑定(实际职务 → 虚拟职务)"
:max-height="300"
pageable
:default-page-size="50"
/>
</el-tab-pane>
</el-tabs>
</el-dialog>
</div>
</template>
@@ -545,6 +658,11 @@ onMounted(loadMarket)
background: linear-gradient(135deg, var(--el-color-primary-light-8), var(--el-color-primary-light-7));
color: var(--el-color-primary);
}
// dark 主题:浅色渐变块刺眼,改用主色低透明度暗底 + 主色图标
:global(html.dark) .app-icon {
background: rgba(64, 158, 255, 0.14);
color: var(--el-color-primary);
}
.app-titles {
flex: 1;
min-width: 0;
@@ -591,6 +709,12 @@ onMounted(loadMarket)
align-items: center;
gap: 8px;
}
/* 状态文本独立一行(不与操作按钮同行,避免"已安装于 X 家公司"等长文本折叠) */
.app-status-row {
display: flex;
align-items: center;
min-height: 18px;
}
.spacer {
flex: 1;
}
@@ -633,6 +757,16 @@ onMounted(loadMarket)
color: var(--el-text-color-secondary);
margin: 10px 0 0;
}
.gs-bind-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin: 0 0 10px;
line-height: 1.6;
}
/* 虚拟职务 Tab 内两个子表的分隔 */
.gs-vp-sep {
height: 14px;
}
/* RAM 角色授权区 */
.gs-role-block {
margin-top: 16px;
+5
View File
@@ -163,6 +163,11 @@ onMounted(loadMyApps)
background: linear-gradient(135deg, var(--el-color-primary-light-8), var(--el-color-primary-light-7));
color: var(--el-color-primary);
}
// dark 主题:浅色渐变块刺眼,改用主色低透明度暗底 + 主色图标
:global(html.dark) .app-icon {
background: rgba(64, 158, 255, 0.14);
color: var(--el-color-primary);
}
.app-titles {
flex: 1;
min-width: 0;
+65 -11
View File
@@ -9,7 +9,7 @@
* - 子表 1:Department(部门),按 CompanyUUID 过滤,点击行跳转部门详情。
* - 子表 2:Workgroup(工作组),按 CompanyUUID 过滤,点击行跳转工作组详情。
* - 子表 3:UserRole(员工关联,实际表名 UserRole),按 CompanyUUID 过滤,
* 点击行跳转员工详情(employee-detail)。
* 点击行跳转员工详情(/system/user-detail 统一用户详情页)。
* - 管理员特权模式:URL 携带 admin_verify=1 时跨公司隔离关闭,由 DetailPage 内置
* AdminBadge 横幅统一提示,无需本页处理。
* - server / admin_verify 参数由 axios 请求拦截器统一注入,子表 CrudTable 与主表
@@ -27,6 +27,8 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import CrudTable from '@/components/CrudTable.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import BatchPositionDialog from '@/components/BatchPositionDialog.vue'
import { formApi } from '@/api/form'
import { upload } from '@/api/request'
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
@@ -171,9 +173,26 @@ function goDeptDetail(row: RowData): void {
function goWorkgroupDetail(row: RowData): void {
router.push({ path: '/company/workgroup-detail', query: { WorkgroupUUID: asStr(row.WorkgroupUUID) } })
}
/** 跳转员工详情(UserRole 行携带 UserUUID) */
/** 跳转员工详情(UserRole 行携带 UserUUID;统一走系统用户详情页) */
function goEmployeeDetail(row: RowData): void {
router.push({ path: '/company/employee-detail', query: { UserUUID: asStr(row.UserUUID) } })
router.push({ path: '/system/user-detail', query: { UserUUID: asStr(row.UserUUID) } })
}
// ===== 员工批量调整职务 =====
const empTableRef = ref()
const batchDialogVisible = ref(false)
const batchPreselected = ref<Array<{ pk: string; label: string }>>([])
/** 打开批量职务弹窗:有选中行 → 应用到选中行;无选中 → 弹窗内按部门树选员工 */
function openBatchDialog(): void {
const rows: Array<RowData> = empTableRef.value?.selectedRows ?? []
batchPreselected.value = rows.map((r) => ({
pk: String(r.UserRoleUUID ?? ''),
label:
`${String((r.RoleName || r.RoleUUID_label) ?? '')}${r.UserUUID_label ? `(${String(r.UserUUID_label)})` : ''}` ||
String(r.UserRoleUUID ?? ''),
}))
batchDialogVisible.value = true
}
// ===== 主表编辑弹窗(对齐原生 showEditDialog)=====
@@ -208,6 +227,10 @@ async function openEdit(): Promise<void> {
}
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, companyData.value)
// 实体选择回显:TenantUUID 名称(entityPicker 依赖 *_label 展示名称而非 UUID)
if (companyData.value.TenantUUID && resolvedNames.value.tenant?.[companyData.value.TenantUUID]) {
editForm.TenantUUID_label = resolvedNames.value.tenant[companyData.value.TenantUUID]
}
editVisible.value = true
}
@@ -315,10 +338,15 @@ watch(companyUUID, () => {
table="Department"
title="部门"
:query="{ CompanyUUID: companyUUID }"
card-mode
card-title-field="DeptName"
card-tree-parent-field="ParentDeptUUID"
:card-fields="['DeptStatus', 'PositionCategoryUUID', 'CreateTime']"
:card-value-maps="{
DeptStatus: { 0: '禁用', 1: '启用' },
}"
:default-page-size="1000"
:selectable="true"
:pageable="true"
:max-height="400"
pk-field="DeptUUID"
:on-view="goDeptDetail"
/>
</el-card>
@@ -336,14 +364,15 @@ watch(companyUUID, () => {
title="工作组"
:query="{ CompanyUUID: companyUUID }"
:selectable="true"
:pageable="true"
:max-height="400"
pk-field="WorkgroupUUID"
card-mode
card-title-field="WorkgroupName"
:card-fields="['WorkgroupEnglishName', 'WorkgroupIntroduction']"
:on-view="goWorkgroupDetail"
/>
</el-card>
<!-- 子表 3:员工(UserRole 关联表) -->
<!-- 子表 3:员工(UserRole:公司全部员工;部门/职务为虚拟列,读写 UserRoleDepartment 关系表) -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
@@ -352,6 +381,7 @@ watch(companyUUID, () => {
</div>
</template>
<CrudTable
ref="empTableRef"
table="UserRole"
title="员工"
:query="{ CompanyUUID: companyUUID }"
@@ -360,7 +390,21 @@ watch(companyUUID, () => {
:max-height="400"
pk-field="UserRoleUUID"
:on-view="goEmployeeDetail"
/>
>
<template #toolbar="{ refresh }">
<el-button type="warning" @click="openBatchDialog">
<el-icon><Edit /></el-icon>批量调整职务
</el-button>
<BatchPositionDialog
v-model:visible="batchDialogVisible"
table="UserRole"
:context="{ CompanyUUID: companyUUID }"
:preselected="batchPreselected"
title="员工批量调整职务"
@success="refresh"
/>
</template>
</CrudTable>
</el-card>
</template>
@@ -397,9 +441,19 @@ watch(companyUUID, () => {
:label="col.title"
:required="col.required"
>
<!-- 实体选择(entityPicker:TenantUUID/CompanyUUID 等自动映射,如租户选择框) -->
<EntityPicker
v-if="col.type === 'entityPicker' && col.entityPicker"
v-model="editForm[col.field]"
:config="col.entityPicker"
:existing-label="editForm[col.field + '_label']"
:placeholder="col.placeholder"
:disabled="col.disabled"
@update:label="(label: string) => (editForm[col.field + '_label'] = label)"
/>
<!-- 文本类 -->
<el-input
v-if="!col.type || col.type === 'text' || col.type === 'string'"
v-else-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
+48 -2
View File
@@ -27,19 +27,24 @@
* - 树形展示暂未实现(TODO:待 vxe-table tree-config 接入)
* - onView 跳 Vue 路由 /company/department-detail?DeptUUID=xxx(替代 GERP.Navigate)
*/
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import CrudTable from '@/components/CrudTable.vue'
import BatchPositionDialog from '@/components/BatchPositionDialog.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('部门管理')
const router = useRouter()
const userStore = useUserStore()
// 部门记录类型(仅声明本页用到的字段)
interface DepartmentRow {
DeptUUID?: string
DeptName?: string
ParentDeptUUID?: string
CompanyUUID?: string
[k: string]: unknown
}
@@ -50,20 +55,61 @@ function handleView(row: DepartmentRow): void {
query: { DeptUUID: row.DeptUUID || '' },
})
}
// ===== 批量调整职务:选中部门 → 应用到其全部员工 =====
const deptListRef = ref()
const batchDialogVisible = ref(false)
const batchPreselected = ref<Array<{ pk: string; label: string }>>([])
const batchCompany = ref('')
/** 打开批量职务弹窗:把选中部门作为目标(后端解析其全部成员) */
function openBatchDialog(): void {
const rows: Array<DepartmentRow> = deptListRef.value?.selectedRows ?? []
batchPreselected.value = rows.map((r) => ({
pk: String(r.DeptUUID ?? ''),
label: String(r.DeptName ?? r.DeptUUID ?? ''),
}))
// 未勾选行时兜底当前登录用户公司,保证部门树只显示本公司(超管也不混入其它公司)
batchCompany.value = String(rows[0]?.CompanyUUID ?? userStore.adminCheck?.current_company_uuid ?? '')
batchDialogVisible.value = true
}
</script>
<template>
<div class="department-page">
<CrudTable
ref="deptListRef"
table="Department"
title="部门管理"
pk-field="DeptUUID"
searchable
pageable
stretch
selectable
card-mode
card-title-field="DeptName"
card-tree-parent-field="ParentDeptUUID"
:card-fields="['DeptStatus', 'PositionCategoryUUID', 'CreateTime']"
:card-value-maps="{
DeptStatus: { 0: '禁用', 1: '启用' },
}"
:default-page-size="1000"
:on-view="handleView"
/>
>
<template #toolbar="{ refresh }">
<el-button type="warning" @click="openBatchDialog">
<el-icon><Edit /></el-icon>批量调整职务
</el-button>
<BatchPositionDialog
v-model:visible="batchDialogVisible"
table="Department"
:context="{ CompanyUUID: batchCompany }"
:preselected="batchPreselected"
title="部门批量调整职务"
@success="refresh"
/>
</template>
</CrudTable>
</div>
</template>
+37 -1
View File
@@ -24,6 +24,7 @@ import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import BatchPositionDialog from '@/components/BatchPositionDialog.vue'
import { formApi } from '@/api/form'
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
import { setPageTitle } from '@/utils/serverScope'
@@ -154,6 +155,23 @@ function goUserDetail(row: Record<string, any>): void {
router.push({ path: '/system/user-detail', query: { UserUUID: row.UserUUID || '' } })
}
// === 批量调整职务 ===
const deptTableRef = ref()
const batchDialogVisible = ref(false)
const batchPreselected = ref<Array<{ pk: string; label: string }>>([])
/** 打开批量职务弹窗:有选中行 → 应用到选中行;无选中 → 弹窗内选部门员工 */
function openBatchDialog(): void {
const rows: Array<Record<string, any>> = deptTableRef.value?.selectedRows ?? []
batchPreselected.value = rows.map((r) => ({
pk: String(r.UserRoleDepartmentUUID ?? ''),
label:
`${String((r.RoleNameCover || r.UserRoleUUID_label) ?? '')}${r.UserUUID_label ? `(${String(r.UserUUID_label)})` : ''}` ||
String(r.UserRoleUUID ?? ''),
}))
batchDialogVisible.value = true
}
onMounted(async () => {
await Promise.all([loadData(), loadColumns()])
})
@@ -193,6 +211,9 @@ watch(deptUUID, () => {
<el-descriptions-item label="上级部门">
{{ parentDeptName || data?.ParentDeptUUID || '-' }}
</el-descriptions-item>
<el-descriptions-item label="通用部门">
{{ data?.PositionCategoryUUID_label || data?.PositionCategoryUUID || '-' }}
</el-descriptions-item>
<el-descriptions-item label="排序">{{ data?.DeptSort ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(data?.DeptCreateTime, false) }}</el-descriptions-item>
</InfoGrid>
@@ -207,6 +228,7 @@ watch(deptUUID, () => {
</template>
<CrudTable
v-if="deptUUID"
ref="deptTableRef"
table="UserRoleDepartment"
title="部门员工"
:query="{ DeptUUID: deptUUID, CompanyUUID: data?.CompanyUUID || '' }"
@@ -215,7 +237,21 @@ watch(deptUUID, () => {
pageable
:max-height="400"
:on-view="goUserDetail"
/>
>
<template #toolbar="{ refresh }">
<el-button type="warning" @click="openBatchDialog">
<el-icon><Edit /></el-icon>批量调整职务
</el-button>
<BatchPositionDialog
v-model:visible="batchDialogVisible"
table="UserRoleDepartment"
:context="{ DeptUUID: deptUUID, CompanyUUID: data?.CompanyUUID || '' }"
:preselected="batchPreselected"
title="部门员工批量调整职务"
@success="refresh"
/>
</template>
</CrudTable>
</el-card>
</template>
-448
View File
@@ -1,448 +0,0 @@
<script setup lang="ts">
/**
* EmployeeDetail 员工详情页
*
* 迁移自原生页面:pages/Company/employee-detail.html
*
* 业务说明:
* - 顶部展示用户基本信息(含头像)+ 安全信息(2FA 状态)。
* - 头像:通过 /Query/GetELTUserAvatar 接口获取(el-avatar 直接以 img 形式请求,
* 不走 axios 拦截器,故需手动拼接 server / admin_verify 作用域参数)。
* - 2FA:调用非标准接口 /API/SiteUserManagement/GetUser2FAInfo 查询双因子状态,
* 查询失败不阻塞页面(降级为“未配置”)。
* - 子表 1:UserRole(角色与组织),按 UserUUID 过滤。
* - 子表 2:UserDept(部门关联),按 UserUUID 过滤,点击行跳转部门详情。
* - server / admin_verify 由 axios 请求拦截器统一注入(2FA 接口亦同)。
*
* 关键实现点:
* - 主表数据通过 formApi.list('User', { UserUUID, pageSize:1 }) 拉取并定位记录。
* - 2FA 通过 get('/API/SiteUserManagement/GetUser2FAInfo', { UserUUID }) 调用,
* 响应拦截器已解包为 data;外层 try/catch 保证失败不阻塞。
* - 头像 URL 手动附加 getServerScopeParams() 返回的 server / admin_verify。
* - 编辑弹窗动态渲染列定义,保存调用 formApi.update(对齐已迁移详情页约定)。
*/
import { ref, reactive, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import { formApi } from '@/api/form'
import { get } from '@/api/request'
import { setPageTitle, getServerScopeParams } from '@/utils/serverScope'
import { formatUtcTime as formatTime } from '@/utils/dateTime'
import type { ColumnSchema } from '@/types/api'
/** 用户记录类型(仅声明详情页用到的字段) */
interface UserRecord {
UserUUID: string
UserName: string
UserRealName: string
UserEmail: string
UserTele: string
UserAccountLicense: number
UserEmailVerify: number
UserGender: number
UserPositionStatus: number
IsStaff: number | boolean
UserCreateTime: string
[key: string]: unknown
}
/** 2FA 信息类型 */
interface TwoFaInfo {
IsEnabled: number
Verified: number
LastUsedAt: string
CreateTime: string
[key: string]: unknown
}
/** 通用行记录类型 */
type RowData = Record<string, unknown>
const route = useRoute()
const router = useRouter()
/** 加载态 */
const loading = ref(false)
/** 用户主数据 */
const userData = ref<UserRecord | null>(null)
/** 2FA 信息(null 表示未获取或未配置) */
const twoFaData = ref<TwoFaInfo | null>(null)
/** 从路由 query 读取用户 UUID(computed 响应式:同一 tab 复用且 query 变化时自动更新) */
const userUUID = computed(() => (route.query.UserUUID as string) || '')
/** 用户主键字段名 */
const PK_FIELD = 'UserUUID'
/** 账号状态映射:0=禁用,1=启用(对齐原版 profile.html:1 正常,0 已封禁) */
const accountStatusMap: Record<string, { text: string; type: 'success' | 'danger' | 'info' }> = {
'0': { text: '禁用', type: 'danger' },
'1': { text: '启用', type: 'success' },
}
/** 邮箱验证映射:0=未认证,1=已认证(对齐原版 profile.html) */
const emailVerifyMap: Record<string, { text: string; type: 'success' | 'danger' | 'info' }> = {
'0': { text: '未认证', type: 'danger' },
'1': { text: '已认证', type: 'success' },
}
/** 职位状态映射:1=在职,0=停职(对齐原版 profile.html) */
const positionStatusMap: Record<string, { text: string; type: 'success' | 'danger' | 'info' }> = {
'1': { text: '在职', type: 'success' },
'0': { text: '停职', type: 'danger' },
}
/** 性别:-1=保密,1=男,0=女(对齐原版 profile.html 性别按钮) */
function genderText(val: unknown): string {
if (val === 1 || val === '1') return '男'
if (val === 0 || val === '0') return '女'
if (val === -1 || val === '-1') return '保密'
return '-'
}
/** 头像 URL:手动附加 server / admin_verify 作用域参数(el-avatar 不走 axios 拦截器) */
const avatarUrl = computed(() => {
const params = new URLSearchParams()
params.set('UserUUID', userUUID.value)
const scope = getServerScopeParams()
if (scope.server) params.set('server', scope.server)
if (scope.admin_verify) params.set('admin_verify', scope.admin_verify)
return `/Query/GetELTUserAvatar?${params.toString()}`
})
/** 安全读取记录字段为字符串 */
function asStr(val: unknown): string {
return val == null || val === '' ? '' : String(val)
}
/** 提取分页响应中的列表(兼容数组和 PagedData) */
function pickList<T = RowData>(res: unknown): T[] {
if (Array.isArray(res)) return res as T[]
const obj = res as { list?: T[] }
return (obj?.list as T[]) || []
}
/** IsStaff 转布尔(兼容 0/1 与 true/false) */
function isTruthy(val: unknown): boolean {
return val === 1 || val === true || val === '1' || val === 'true'
}
/** 拉取用户主数据 + 2FA 信息(2FA 失败不阻塞) */
async function loadUser(): Promise<void> {
if (!userUUID.value) {
// 无 UserUUID 参数:不弹警告(首次无参进入/菜单误入均静默),显示空态引导
userData.value = null
setPageTitle('员工详情')
return
}
loading.value = true
twoFaData.value = null
try {
const res = await formApi.list<UserRecord>('User', { UserUUID: userUUID.value, pageSize: 1 })
const list = pickList<UserRecord>(res)
const data = list.find((u) => u.UserUUID === userUUID.value) || list[0]
if (!data) {
return
}
userData.value = data
setPageTitle(asStr(data.UserName) || '员工详情')
// 并行获取 2FA 信息(失败不阻塞页面)
try {
twoFaData.value = await get<TwoFaInfo>('/API/SiteUserManagement/GetUser2FAInfo', {
UserUUID: userUUID.value,
})
} catch {
twoFaData.value = null
}
} catch {
// 错误提示已由响应拦截器统一弹出,数据未加载成功时展示空态
} finally {
loading.value = false
}
}
// ===== 子表行跳转(onView)=====
/** 跳转部门详情(UserDept 行携带 DeptUUID) */
function goDeptDetail(row: RowData): void {
router.push({ path: '/company/department-detail', query: { DeptUUID: asStr(row.DeptUUID) } })
}
// ===== 主表编辑弹窗(对齐原生 showEditDialog)=====
const editVisible = ref(false)
const editSubmitting = ref(false)
const editColumns = ref<ColumnSchema[]>([])
const editForm = reactive<Record<string, any>>({})
/** 弹窗内可见的表单字段 */
const editFormColumns = computed<ColumnSchema[]>(() =>
editColumns.value.filter(
(c) =>
c.editable !== false &&
!c.hideInForm &&
c.type !== 'hidden' &&
c.type !== 'divider' &&
c.type !== 'html' &&
c.field !== PK_FIELD,
),
)
/** 打开编辑弹窗 */
async function openEdit(): Promise<void> {
if (!userData.value) return
try {
if (!editColumns.value.length) {
editColumns.value = await formApi.getColumns('User')
}
} catch {
editColumns.value = []
}
Object.keys(editForm).forEach((k) => delete editForm[k])
Object.assign(editForm, userData.value)
editVisible.value = true
}
/** 保存编辑 */
async function saveEdit(): Promise<void> {
if (!userData.value) return
editSubmitting.value = true
try {
await formApi.update('User', userData.value[PK_FIELD] as string, { ...editForm })
ElMessage.success('保存成功')
editVisible.value = false
await loadUser()
} catch {
// 错误提示已由响应拦截器统一弹出
} finally {
editSubmitting.value = false
}
}
onMounted(loadUser)
// 同一 tab 复用(v-show 常驻)时 query 变化:重新加载(防展示旧员工数据 / 缺参报错)
// immediate 使首次挂载也走统一逻辑(与 onMounted 的 loadUser 幂等);
// 空值场景静默显示空态(loadUser 内处理),不再弹警告
watch(userUUID, () => {
void loadUser()
}, { immediate: true })
</script>
<template>
<DetailPage :title="userData?.UserName || '员工详情'" :loading="loading">
<!-- 右上角操作按钮 -->
<template #actions>
<el-button type="primary" :disabled="!userData" @click="openEdit">
<el-icon><Edit /></el-icon>编辑
</el-button>
</template>
<!-- 无 UserUUID 参数或数据未加载成功:空态引导 -->
<el-empty
v-if="!userData"
description="员工数据未加载成功,请确认登录状态后从员工列表进入详情"
:image-size="80"
/>
<template v-else>
<!-- 基本信息(头像 + 信息网格横向排列,直接置于 DetailPage,不包 el-card) -->
<div class="profile-row">
<!-- 头像:失败时显示默认图标 -->
<el-avatar :size="96" :src="avatarUrl" class="profile-avatar">
<el-icon :size="32"><User /></el-icon>
</el-avatar>
<div class="profile-info">
<el-descriptions title="基本信息" :column="2" border>
<el-descriptions-item label="用户名">{{ asStr(userData.UserName) || '-' }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ asStr(userData.UserRealName) || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ asStr(userData.UserEmail) || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ asStr(userData.UserTele) || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱验证">
<el-tag :type="emailVerifyMap[String(userData.UserEmailVerify)]?.type || 'info'" size="small">
{{ emailVerifyMap[String(userData.UserEmailVerify)]?.text ?? userData.UserEmailVerify }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(userData.UserGender) }}</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="accountStatusMap[String(userData.UserAccountLicense)]?.type || 'info'" size="small">
{{ accountStatusMap[String(userData.UserAccountLicense)]?.text ?? userData.UserAccountLicense }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="职位状态">
<el-tag :type="positionStatusMap[String(userData.UserPositionStatus)]?.type || 'info'" size="small">
{{ positionStatusMap[String(userData.UserPositionStatus)]?.text ?? userData.UserPositionStatus }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否员工">
<span :style="{ color: isTruthy(userData.IsStaff) ? '#166534' : '#991b1b' }">
{{ isTruthy(userData.IsStaff) ? '是' : '否' }}
</span>
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(userData.UserCreateTime) }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
<!-- 安全信息(2FA,InfoGrid 直接置于 DetailPage,不包 el-card) -->
<InfoGrid title="安全信息" :columns="2">
<el-descriptions-item label="2FA状态">
<span v-if="twoFaData" :style="{ color: twoFaData.IsEnabled === 1 ? '#166534' : '#991b1b' }">
{{ twoFaData.IsEnabled === 1 ? '已启用' : '未启用' }}
</span>
<span v-else style="color: #6b7280">未配置</span>
</el-descriptions-item>
<el-descriptions-item label="验证状态">
<span v-if="twoFaData" :style="{ color: twoFaData.Verified === 1 ? '#166534' : '#991b1b' }">
{{ twoFaData.Verified === 1 ? '已验证' : '未验证' }}
</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="最后使用">
{{ twoFaData && twoFaData.LastUsedAt ? formatTime(twoFaData.LastUsedAt) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="2FA创建时间">
{{ twoFaData && twoFaData.CreateTime ? formatTime(twoFaData.CreateTime) : '-' }}
</el-descriptions-item>
</InfoGrid>
<!-- 子表 1:角色与组织(UserRole) -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Avatar /></el-icon>
<span>角色与组织</span>
</div>
</template>
<CrudTable
table="UserRole"
title="角色与组织"
:query="{ UserUUID: userUUID }"
:selectable="true"
:pageable="true"
:max-height="400"
pk-field="UserRoleUUID"
/>
</el-card>
<!-- 子表 2:部门关联(UserDept) -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Share /></el-icon>
<span>部门关联</span>
</div>
</template>
<CrudTable
table="UserDept"
title="部门关联"
:query="{ UserUUID: userUUID }"
:selectable="true"
:pageable="true"
:max-height="400"
pk-field="UserDeptUUID"
:on-view="goDeptDetail"
/>
</el-card>
</template>
<!-- 主表编辑弹窗 -->
<el-dialog v-model="editVisible" title="编辑员工" width="600px" destroy-on-close>
<el-form :model="editForm" label-width="120px" label-position="right">
<el-form-item
v-for="col in editFormColumns"
:key="col.field"
:label="col.title"
:required="col.required"
>
<el-input
v-if="!col.type || col.type === 'text' || col.type === 'string'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:maxlength="col.maxLength"
:disabled="col.disabled"
/>
<el-input-number
v-else-if="col.type === 'number'"
v-model="editForm[col.field]"
:min="col.min"
:max="col.max"
:step="col.step"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'textarea'"
v-model="editForm[col.field]"
type="textarea"
:rows="col.rows || 3"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'password'"
v-model="editForm[col.field]"
type="password"
show-password
:placeholder="col.placeholder"
/>
<el-select
v-else-if="col.type === 'select'"
v-model="editForm[col.field]"
:placeholder="col.placeholder"
:disabled="col.disabled"
style="width: 100%"
>
<el-option
v-for="opt in col.options"
:key="String(opt.value)"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-date-picker
v-else-if="col.type === 'date' || col.type === 'datetime'"
v-model="editForm[col.field]"
:type="col.type as 'date' | 'datetime'"
:placeholder="col.placeholder"
style="width: 100%"
/>
<el-switch
v-else-if="col.type === 'switch' || col.type === 'boolean'"
v-model="editForm[col.field]"
:disabled="col.disabled"
/>
<el-input
v-else-if="col.type === 'readonly'"
:model-value="String(editForm[col.field] ?? '')"
readonly
/>
<el-input v-else v-model="editForm[col.field]" :placeholder="col.placeholder" :disabled="col.disabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="editVisible = false">取消</el-button>
<el-button type="primary" :loading="editSubmitting" @click="saveEdit">保存</el-button>
</template>
</el-dialog>
</DetailPage>
</template>
<style scoped lang="scss">
// 头像与信息网格横向排列
.profile-row {
display: flex;
gap: 24px;
align-items: flex-start;
}
.profile-avatar {
flex-shrink: 0;
border: 2px solid #e5e7eb;
}
.profile-info {
flex: 1;
min-width: 0;
}
</style>
+8
View File
@@ -62,6 +62,14 @@ function handleView(row: RoleRow): void {
pageable
stretch
selectable
card-mode
card-title-field="RoleName"
:card-fields="['RoleType', 'RoleStatus', 'AssignScope', 'RoleDesc', 'CreateTime']"
:card-value-maps="{
RoleType: { 0: '组员', 1: '组长', 2: '其他' },
RoleStatus: { 0: '禁用', 1: '启用' },
AssignScope: { 0: '全租户', 1: '公司', 2: '部门' },
}"
:on-view="handleView"
/>
</div>
+9 -2
View File
@@ -350,8 +350,15 @@ watch(tenantUUID, () => {
:query="{ TenantUUID: tenantUUID }"
:pk-field="'CompanyUUID'"
selectable
pageable
:max-height="400"
card-mode
card-title-field="CompanyName"
card-icon-field="CompanyIcon"
card-tree-parent-field="ParentCompanyUUID"
:default-page-size="1000"
:card-fields="['CompanyShort', 'CompanyStatus', 'ParentCompanyUUID', 'CreateTime']"
:card-value-maps="{
CompanyStatus: { 0: '禁用', 1: '启用' },
}"
:on-view="goCompanyDetail"
/>
</el-card>
+42 -1
View File
@@ -24,18 +24,23 @@
* - role_scope 细粒度判断暂用 userStore.isAdmin 二分(TODO 待 checkAdmin 接入)
* - onView 跳 Vue 路由 /company/workgroup-detail?WorkgroupUUID=xxx(替代 GERP.Navigate)
*/
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import CrudTable from '@/components/CrudTable.vue'
import BatchPositionDialog from '@/components/BatchPositionDialog.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('工作组管理')
const router = useRouter()
const userStore = useUserStore()
// 工作组记录类型(仅声明本页用到的字段)
interface WorkgroupRow {
WorkgroupUUID?: string
WorkgroupName?: string
CompanyUUID?: string
[k: string]: unknown
}
@@ -46,11 +51,30 @@ function handleView(row: WorkgroupRow): void {
query: { WorkgroupUUID: row.WorkgroupUUID || '' },
})
}
// ===== 批量调整职务:选中工作组 → 应用到其全部成员 =====
const wgListRef = ref()
const batchDialogVisible = ref(false)
const batchPreselected = ref<Array<{ pk: string; label: string }>>([])
const batchCompany = ref('')
/** 打开批量职务弹窗:把选中工作组作为目标(后端解析其全部成员) */
function openBatchDialog(): void {
const rows: Array<WorkgroupRow> = wgListRef.value?.selectedRows ?? []
batchPreselected.value = rows.map((r) => ({
pk: String(r.WorkgroupUUID ?? ''),
label: String(r.WorkgroupName ?? r.WorkgroupUUID ?? ''),
}))
// 未勾选行时兜底当前登录用户公司,保证工作组树只显示本公司(超管也不混入其它公司)
batchCompany.value = String(rows[0]?.CompanyUUID ?? userStore.adminCheck?.current_company_uuid ?? '')
batchDialogVisible.value = true
}
</script>
<template>
<div class="workgroup-page">
<CrudTable
ref="wgListRef"
table="Workgroup"
title="工作组管理"
pk-field="WorkgroupUUID"
@@ -58,8 +82,25 @@ function handleView(row: WorkgroupRow): void {
pageable
stretch
selectable
card-mode
card-title-field="WorkgroupName"
:card-fields="['WorkgroupEnglishName', 'WorkgroupIntroduction', 'CompanyUUID']"
:on-view="handleView"
/>
>
<template #toolbar="{ refresh }">
<el-button type="warning" @click="openBatchDialog">
<el-icon><Edit /></el-icon>批量调整职务
</el-button>
<BatchPositionDialog
v-model:visible="batchDialogVisible"
table="Workgroup"
:context="{ CompanyUUID: batchCompany }"
:preselected="batchPreselected"
title="工作组批量调整职务"
@success="refresh"
/>
</template>
</CrudTable>
</div>
</template>
+34 -1
View File
@@ -23,6 +23,7 @@ import DetailPage from '@/components/DetailPage.vue'
import InfoGrid from '@/components/InfoGrid.vue'
import CrudTable from '@/components/CrudTable.vue'
import EntityPicker from '@/components/EntityPicker.vue'
import BatchPositionDialog from '@/components/BatchPositionDialog.vue'
import { formApi } from '@/api/form'
import { useResolveUUIDs } from '@/composables/useResolveUUIDs'
import { setPageTitle } from '@/utils/serverScope'
@@ -151,6 +152,23 @@ function goUserDetail(row: Record<string, any>): void {
router.push({ path: '/system/user-detail', query: { UserUUID: row.UserUUID || '' } })
}
// === 批量调整职务 ===
const wgTableRef = ref()
const batchDialogVisible = ref(false)
const batchPreselected = ref<Array<{ pk: string; label: string }>>([])
/** 打开批量职务弹窗:有选中行 → 应用到选中行;无选中 → 弹窗内选工作组成员 */
function openBatchDialog(): void {
const rows: Array<Record<string, any>> = wgTableRef.value?.selectedRows ?? []
batchPreselected.value = rows.map((r) => ({
pk: String(r.UserRoleWorkgroupUUID ?? ''),
label:
`${String((r.RoleNameCover || r.UserRoleUUID_label) ?? '')}${r.UserUUID_label ? `(${String(r.UserUUID_label)})` : ''}` ||
String(r.UserRoleUUID ?? ''),
}))
batchDialogVisible.value = true
}
onMounted(async () => {
await Promise.all([loadData(), loadColumns()])
})
@@ -199,6 +217,7 @@ watch(workgroupUUID, () => {
</template>
<CrudTable
v-if="workgroupUUID"
ref="wgTableRef"
table="UserRoleWorkgroup"
title="工作组员工"
:query="{ WorkgroupUUID: workgroupUUID, CompanyUUID: data?.CompanyUUID || '' }"
@@ -207,7 +226,21 @@ watch(workgroupUUID, () => {
pageable
:max-height="400"
:on-view="goUserDetail"
/>
>
<template #toolbar="{ refresh }">
<el-button type="warning" @click="openBatchDialog">
<el-icon><Edit /></el-icon>批量调整职务
</el-button>
<BatchPositionDialog
v-model:visible="batchDialogVisible"
table="UserRoleWorkgroup"
:context="{ WorkgroupUUID: workgroupUUID, CompanyUUID: data?.CompanyUUID || '' }"
:preselected="batchPreselected"
title="工作组员工批量调整职务"
@success="refresh"
/>
</template>
</CrudTable>
</el-card>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 考核管理(eHRM 人力资源管理扩展)
*
* 员工考核表:考核期间 / 考核人 / 评分 / 等级 / 结论 / 状态(草稿/已确认)。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('考核管理')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="ehrm-page">
<AdminBadge />
<CrudTable
table="EmployeeAssessment"
title="员工考核"
pk-field="AssessmentUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.ehrm-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 绩效管理(eHRM 人力资源管理扩展)
*
* 员工绩效表:绩效期间 / 指标 / 目标值 / 实际值 / 得分 / 权重。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('绩效管理')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="ehrm-page">
<AdminBadge />
<CrudTable
table="EmployeePerformance"
title="员工绩效"
pk-field="PerformanceUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.ehrm-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 档案管理(eHRM 人力资源管理扩展)
*
* 员工档案表:工号 / 部门 / 入职日期 / 职务 / 学历 / 紧急联系人 / 地址等。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('档案管理')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="ehrm-page">
<AdminBadge />
<CrudTable
table="EmployeeProfile"
title="员工档案"
pk-field="EmployeeProfileUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.ehrm-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 应收应付(鼎算财务)
*
* 往来账款:应收/应付类型、总额、已收付与余额、到期日与结算状态。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('应收应付')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="finance-page">
<AdminBadge />
<CrudTable
table="FinanceArap"
title="应收应付"
pk-field="ArapUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.finance-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 预算管理(鼎算财务)
*
* 期间 × 部门/科目的预算额度与已执行金额。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('预算管理')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="finance-page">
<AdminBadge />
<CrudTable
table="FinanceBudget"
title="预算管理"
pk-field="BudgetUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.finance-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 出纳管理(鼎算财务)
*
* 出纳流水:收入/支出/转账记录,结余由系统按上一笔流水计算。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('出纳管理')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="finance-page">
<AdminBadge />
<CrudTable
table="FinanceCashier"
title="出纳流水"
pk-field="CashierUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.finance-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 会计账簿(鼎算财务)
*
* 科目发生额与余额:按期间/科目记录借方、贷方与结余。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('会计账簿')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="finance-page">
<AdminBadge />
<CrudTable
table="FinanceLedger"
title="会计账簿"
pk-field="LedgerUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.finance-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 费用报销(鼎算财务)
*
* 报销单与审批流:待审批 → 已通过/已驳回 → 已打款。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('费用报销')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="finance-page">
<AdminBadge />
<CrudTable
table="FinanceReimburse"
title="费用报销"
pk-field="ReimburseUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.finance-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
/**
* 财务报表(鼎算财务)
*
* 报表快照:按报表类型(利润表/资产负债表)与会计期间读取 FinanceReport 项目行,
* 展示项目编码/名称/本期金额/累计金额,并给出合计行。
*/
import { ref } from 'vue'
import { formApi } from '@/api/form'
import { setPageTitle } from '@/utils/serverScope'
interface ReportRow {
ReportUUID: string
ReportType?: number
Period?: string
ItemCode?: string
ItemName?: string
CurrentAmount?: number
CumulativeAmount?: number
[k: string]: unknown
}
setPageTitle('财务报表')
const reportType = ref(1)
const period = ref('')
const loading = ref(false)
const rows = ref<ReportRow[]>([])
const typeText: Record<number, string> = { 1: '利润表', 2: '资产负债表' }
async function load(): Promise<void> {
loading.value = true
try {
const res = await formApi.list<ReportRow>('FinanceReport', {
ReportType: reportType.value,
...(period.value ? { Period: period.value } : {}),
page: 1,
pageSize: 500,
})
rows.value = res.list || []
} catch {
// 拦截器已提示
} finally {
loading.value = false
}
}
function switchType(t: string | number | boolean | undefined): void {
reportType.value = Number(t)
load()
}
const sumCurrent = () => rows.value.reduce((s, r) => s + (Number(r.CurrentAmount) || 0), 0)
const sumCumulative = () => rows.value.reduce((s, r) => s + (Number(r.CumulativeAmount) || 0), 0)
const fmt = (v: number | undefined) => (Number(v) || 0).toFixed(2)
</script>
<template>
<div class="report-page">
<el-card shadow="never" class="report-card">
<template #header>
<div class="report-header">
<el-radio-group v-model="reportType" @change="switchType">
<el-radio-button :value="1">利润表</el-radio-button>
<el-radio-button :value="2">资产负债表</el-radio-button>
</el-radio-group>
<div class="report-filters">
<el-input
v-model="period"
placeholder="会计期间,如 2026-08"
clearable
style="width: 180px"
@keyup.enter="load"
@clear="load"
/>
<el-button type="primary" :icon="'Search'" :loading="loading" @click="load">查询</el-button>
<el-button :icon="'Refresh'" @click="load">刷新</el-button>
</div>
</div>
</template>
<el-table v-loading="loading" :data="rows" border size="default" stripe>
<el-table-column type="index" label="#" width="56" align="center" />
<el-table-column prop="ItemCode" label="项目编码" width="160" />
<el-table-column prop="ItemName" label="项目名称" min-width="180" />
<el-table-column label="本期金额" width="160" align="right">
<template #default="{ row }">{{ fmt(row.CurrentAmount) }}</template>
</el-table-column>
<el-table-column label="累计金额" width="160" align="right">
<template #default="{ row }">{{ fmt(row.CumulativeAmount) }}</template>
</el-table-column>
<template #append>
<div v-if="rows.length" class="report-total">
<span>合计:</span>
<span class="total-num">{{ fmt(sumCurrent()) }}</span>
<span class="total-num">{{ fmt(sumCumulative()) }}</span>
</div>
</template>
</el-table>
<el-empty v-if="!loading && rows.length === 0" description="暂无报表数据,请在凭证过账后生成或手工维护报表项目" />
<div class="report-tip">
当前报表:{{ typeText[reportType] }}{{ period ? ' · 期间 ' + period : ' · 全部期间' }}
</div>
</el-card>
</div>
</template>
<style scoped lang="scss">
.report-page {
padding: 20px;
height: 100%;
box-sizing: border-box;
overflow: auto;
}
.report-card {
height: 100%;
}
.report-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.report-filters {
display: flex;
align-items: center;
gap: 8px;
}
.report-total {
display: flex;
justify-content: flex-end;
gap: 40px;
padding: 12px 20px;
font-weight: 600;
font-size: 14px;
.total-num {
width: 160px;
text-align: right;
}
}
.report-tip {
margin-top: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
+346
View File
@@ -0,0 +1,346 @@
<script setup lang="ts">
/**
* 凭证管理(鼎算财务)
*
* 记账凭证:主表 FinanceVoucher(凭证头)+ 分录子表 FinanceVoucherEntry(借贷行)。
* - 列表:CrudTable 展示凭证头,状态/借贷合计由后端列配置渲染
* - 查看/编辑:点击行打开凭证详情弹窗,行内编辑分录(科目/摘要/借贷金额)
* - 保存:先更新主表(借贷合计由分录汇总自动回写),再整组重写分录(先删后插)
* - 过账/作废:通过状态流转按钮修改主表 Status
*/
import { computed, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { formApi } from '@/api/form'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
interface VoucherRow {
VoucherUUID: string
VoucherCode?: string
VoucherDate?: string
Period?: string
Summary?: string
TotalDebit?: number
TotalCredit?: number
Status?: number
[k: string]: unknown
}
interface EntryRow {
VoucherEntryUUID?: string
AccountCode: string
AccountName: string
EntrySummary: string
DebitAmount: number
CreditAmount: number
SortOrder: number
}
const userStore = useUserStore()
setPageTitle('凭证管理')
const isAdmin = computed(() => userStore.isAdmin)
// ===== 凭证详情/分录编辑弹窗 =====
const detailVisible = ref(false)
const saving = ref(false)
const currentVoucher = ref<VoucherRow | null>(null)
const entryList = ref<EntryRow[]>([])
const editForm = reactive({
VoucherCode: '',
VoucherDate: '',
Period: '',
Summary: '',
Remark: '',
})
const sumDebit = computed(() => entryList.value.reduce((s, e) => s + (Number(e.DebitAmount) || 0), 0))
const sumCredit = computed(() => entryList.value.reduce((s, e) => s + (Number(e.CreditAmount) || 0), 0))
const diff = computed(() => Math.round((sumDebit.value - sumCredit.value) * 100) / 100)
const balanced = computed(() => entryList.value.length > 0 && diff.value === 0)
const voucherStatus = computed(() => currentVoucher.value?.Status ?? 0)
const statusText: Record<number, string> = { 0: '草稿', 1: '已过账', 2: '已作废' }
const statusTag: Record<number, string> = { 0: 'info', 1: 'success', 2: 'danger' }
function newEntry(order: number): EntryRow {
return { AccountCode: '', AccountName: '', EntrySummary: '', DebitAmount: 0, CreditAmount: 0, SortOrder: order }
}
async function openDetail(row: VoucherRow): Promise<void> {
currentVoucher.value = row
editForm.VoucherCode = row.VoucherCode || ''
editForm.VoucherDate = row.VoucherDate || ''
editForm.Period = row.Period || ''
editForm.Summary = row.Summary || ''
editForm.Remark = (row.Remark as string) || ''
entryList.value = []
detailVisible.value = true
try {
const res = await formApi.list<EntryRow>('FinanceVoucherEntry', {
VoucherUUID: row.VoucherUUID,
page: 1,
pageSize: 200,
})
entryList.value = (res.list || []).map((e: EntryRow) => ({
VoucherEntryUUID: e.VoucherEntryUUID,
AccountCode: e.AccountCode || '',
AccountName: e.AccountName || '',
EntrySummary: e.EntrySummary || '',
DebitAmount: Number(e.DebitAmount) || 0,
CreditAmount: Number(e.CreditAmount) || 0,
SortOrder: e.SortOrder ?? 0,
}))
if (entryList.value.length === 0) entryList.value.push(newEntry(0))
} catch {
entryList.value.push(newEntry(0))
}
}
function addEntry(): void {
entryList.value.push(newEntry(entryList.value.length))
}
function removeEntry(index: number): void {
entryList.value.splice(index, 1)
}
/** 保存:更新主表(含借贷合计)+ 整组重写分录 */
async function saveVoucher(): Promise<void> {
if (!currentVoucher.value) return
if (!balanced.value) {
ElMessage.warning(diff.value !== 0 ? `借贷不平衡,差额 ${diff.value.toFixed(2)}` : '请至少添加一行分录')
return
}
const id = currentVoucher.value.VoucherUUID
saving.value = true
try {
const total = (n: number) => Math.round(n * 100) / 100
await formApi.update('FinanceVoucher', id, {
VoucherCode: editForm.VoucherCode,
VoucherDate: editForm.VoucherDate || undefined,
Period: editForm.Period,
Summary: editForm.Summary,
Remark: editForm.Remark,
TotalDebit: total(sumDebit.value),
TotalCredit: total(sumCredit.value),
})
// 整组重写分录:先删旧再插新(保持事务语义:失败则回滚主表更新)
for (const e of entryList.value) {
if (e.VoucherEntryUUID) {
await formApi.remove('FinanceVoucherEntry', e.VoucherEntryUUID)
}
}
for (let i = 0; i < entryList.value.length; i++) {
const e = entryList.value[i]
await formApi.create('FinanceVoucherEntry', {
VoucherUUID: id,
AccountCode: e.AccountCode,
AccountName: e.AccountName,
EntrySummary: e.EntrySummary,
DebitAmount: e.DebitAmount || 0,
CreditAmount: e.CreditAmount || 0,
SortOrder: i,
})
}
if (currentVoucher.value) {
currentVoucher.value.TotalDebit = total(sumDebit.value)
currentVoucher.value.TotalCredit = total(sumCredit.value)
}
ElMessage.success('凭证保存成功')
} catch {
// 拦截器已提示
} finally {
saving.value = false
}
}
/** 过账/作废:状态流转(已过账/已作废不可再编辑分录) */
async function changeStatus(next: number, label: string): Promise<void> {
if (!currentVoucher.value) return
if (next === 1 && !balanced.value) {
ElMessage.warning('借贷不平衡,不能过账')
return
}
try {
await ElMessageBox.confirm(`确认将凭证${label}?`, '提示', { type: 'warning' })
} catch {
return
}
const id = currentVoucher.value.VoucherUUID
const payload: Record<string, unknown> = { Status: next }
if (next === 1) {
payload.AuditorUUID = userStore.userInfo?.UserUUID || undefined
payload.AuditTime = new Date().toISOString().slice(0, 19).replace('T', ' ')
}
try {
await formApi.update('FinanceVoucher', id, payload)
if (currentVoucher.value) currentVoucher.value.Status = next
ElMessage.success(`凭证已${label}`)
} catch {
// 拦截器已提示
}
}
</script>
<template>
<div class="finance-page">
<AdminBadge />
<CrudTable
table="FinanceVoucher"
title="凭证管理"
pk-field="VoucherUUID"
searchable
pageable
stretch
:selectable="isAdmin"
:on-view="openDetail"
/>
</div>
<!-- 凭证详情/分录编辑弹窗 -->
<el-dialog
v-model="detailVisible"
title="凭证详情"
width="900px"
:close-on-click-modal="false"
destroy-on-close
>
<template v-if="currentVoucher">
<el-descriptions :column="3" border size="small" class="voucher-head">
<el-descriptions-item label="凭证字号">
<el-input v-model="editForm.VoucherCode" size="small" placeholder="如 记-0001" class="head-input" />
</el-descriptions-item>
<el-descriptions-item label="凭证日期">
<el-date-picker
v-model="editForm.VoucherDate"
type="date"
size="small"
value-format="YYYY-MM-DD"
placeholder="选择日期"
class="head-input"
/>
</el-descriptions-item>
<el-descriptions-item label="会计期间">
<el-input v-model="editForm.Period" size="small" placeholder="如 2026-08" class="head-input" />
</el-descriptions-item>
<el-descriptions-item label="摘要" :span="2">
<el-input v-model="editForm.Summary" size="small" placeholder="凭证摘要" />
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="statusTag[voucherStatus] as any" size="small">{{ statusText[voucherStatus] || voucherStatus }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-table :data="entryList" border size="small" class="entry-table" max-height="300">
<el-table-column type="index" label="#" width="44" align="center" />
<el-table-column label="科目编码" min-width="110">
<template #default="{ row }">
<el-input v-model="row.AccountCode" size="small" placeholder="科目编码" />
</template>
</el-table-column>
<el-table-column label="科目名称" min-width="120">
<template #default="{ row }">
<el-input v-model="row.AccountName" size="small" placeholder="科目名称" />
</template>
</el-table-column>
<el-table-column label="分录摘要" min-width="150">
<template #default="{ row }">
<el-input v-model="row.EntrySummary" size="small" placeholder="分录摘要" />
</template>
</el-table-column>
<el-table-column label="借方金额" width="140">
<template #default="{ row }">
<el-input-number v-model="row.DebitAmount" :min="0" :precision="2" :controls="false" size="small" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="贷方金额" width="140">
<template #default="{ row }">
<el-input-number v-model="row.CreditAmount" :min="0" :precision="2" :controls="false" size="small" style="width: 100%" />
</template>
</el-table-column>
<el-table-column width="56" align="center">
<template #default="{ $index }">
<el-button link type="danger" :icon="'Delete'" @click="removeEntry($index)" />
</template>
</el-table-column>
</el-table>
<div class="entry-toolbar">
<el-button size="small" :icon="'Plus'" @click="addEntry">添加分录</el-button>
<div class="entry-summary">
<span>借方合计:<b>{{ sumDebit.toFixed(2) }}</b></span>
<span>贷方合计:<b>{{ sumCredit.toFixed(2) }}</b></span>
<el-tag :type="balanced ? 'success' : 'danger'" size="small">
{{ balanced ? '借贷平衡' : `差额 ${diff.toFixed(2)}` }}
</el-tag>
</div>
</div>
<el-input v-model="editForm.Remark" size="small" placeholder="备注" class="remark-input" />
</template>
<template #footer>
<el-button :icon="'DocumentChecked'" type="primary" :loading="saving" @click="saveVoucher">保存</el-button>
<el-button
v-if="voucherStatus === 0"
:icon="'CircleCheck'"
type="success"
plain
@click="changeStatus(1, '过账')"
>
过账
</el-button>
<el-button
v-if="voucherStatus !== 2"
:icon="'Close'"
type="danger"
plain
@click="changeStatus(2, '作废')"
>
作废
</el-button>
<el-button @click="detailVisible = false">关闭</el-button>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.finance-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.voucher-head {
margin-bottom: 12px;
.head-input {
width: 100%;
}
}
.entry-table {
width: 100%;
}
.entry-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
margin: 8px 0;
.entry-summary {
display: flex;
align-items: center;
gap: 14px;
font-size: 13px;
}
}
.remark-input {
width: 100%;
}
</style>
+214
View File
@@ -0,0 +1,214 @@
<script setup lang="ts">
/**
* 职务管理(Position)——职务模板目录
*
* 内置职务分类(编码前缀):FIN 财务 / OPS 运维 / HR 人事 / ADM 行政 / GEN 通用。
* Position 表为"人事职务标签模板",按租户隔离(TenantUUID),仅人事标注,与角色权限解耦。
* 职务绑定通用部门(PositionCategory)后,新增职务编码自动按"部门缩写+序号"生成。
*
* 展示约定(两级分组):
* - 第一级:按租户分组,每个租户一个"大容器"(标题=租户名)
* - 第二级:租户容器内按通用部门分组,同一个部门的职务放一块(标题=通用部门名)
* - 顶部保留租户筛选:选中时仅显示该租户容器,清除则显示全部
* - 新增自动归属容器租户+通用部门(CrudTable query 注入 TenantUUID/CategoryUUID)
*/
import { ref, computed, onMounted } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import { get } from '@/api/request'
import { formApi } from '@/api/form'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('职务管理')
interface TenantOption {
TenantUUID: string
TenantName: string
}
interface CategoryOption {
PositionCategoryUUID: string
CategoryName: string
}
const tenantOptions = ref<TenantOption[]>([])
const selectedTenant = ref('')
const tenantLoading = ref(false)
/** 租户 → 其通用部门列表(用于租户容器内按部门分组) */
const categoriesByTenant = ref<Record<string, CategoryOption[]>>({})
// 租户筛选:选中时仅显示该租户容器,清除则显示全部
const visibleTenants = computed<TenantOption[]>(() =>
selectedTenant.value
? tenantOptions.value.filter((t) => t.TenantUUID === selectedTenant.value)
: tenantOptions.value,
)
// 拉取租户列表(TenantSearch 每页 20 条,循环拉全量;普通用户仅返回自己的租户)
async function loadTenants(): Promise<void> {
tenantLoading.value = true
try {
const all: TenantOption[] = []
let offset = 0
for (;;) {
const res = await get<{ list: TenantOption[]; has_more?: boolean }>('/API/FormAPI/TenantSearch', { offset })
const list = res?.list || []
all.push(...list)
if (!res?.has_more || list.length === 0) break
offset += list.length
}
tenantOptions.value = all
await Promise.all(all.map(loadCategories))
} catch {
// 错误提示已由响应拦截器统一弹出
} finally {
tenantLoading.value = false
}
}
/** 拉取指定租户的通用部门列表(FormAPI 按租户过滤) */
async function loadCategories(tenant: TenantOption): Promise<void> {
try {
const result = await formApi.list('PositionCategory', { TenantUUID: tenant.TenantUUID, pageSize: 100 })
const list: CategoryOption[] = Array.isArray(result) ? result : (result as any)?.list || []
categoriesByTenant.value[tenant.TenantUUID] = list
} catch {
categoriesByTenant.value[tenant.TenantUUID] = []
}
}
onMounted(loadTenants)
</script>
<template>
<div class="position-page" v-loading="tenantLoading">
<!-- 租户筛选 -->
<div class="tenant-filter">
<span class="tenant-filter-label">租户</span>
<el-select
v-model="selectedTenant"
placeholder="全部租户"
clearable
filterable
:loading="tenantLoading"
style="width: 240px"
>
<el-option
v-for="t in tenantOptions"
:key="t.TenantUUID"
:label="t.TenantName"
:value="t.TenantUUID"
/>
</el-select>
</div>
<!-- 第一级:按租户分组 -->
<div
v-for="tenant in visibleTenants"
:key="tenant.TenantUUID"
class="tenant-container"
>
<div class="tenant-container-title">
<span class="tenant-title-dot" />
<span class="tenant-title-text">{{ tenant.TenantName }}</span>
</div>
<!-- 第二级:租户容器内按通用部门分组,同一部门的职务放一块 -->
<div
v-for="cat in categoriesByTenant[tenant.TenantUUID] || []"
:key="cat.PositionCategoryUUID"
class="category-container"
>
<div class="category-container-title">{{ cat.CategoryName }}</div>
<CrudTable
table="Position"
:title="`${cat.CategoryName} · 职务`"
pk-field="PositionUUID"
:query="{ TenantUUID: tenant.TenantUUID, CategoryUUID: cat.PositionCategoryUUID }"
searchable
pageable
stretch
selectable
/>
</div>
<el-empty
v-if="!(categoriesByTenant[tenant.TenantUUID] || []).length"
description="该租户暂无通用部门,请先到通用部门页创建"
:image-size="70"
/>
</div>
<el-empty v-if="!tenantLoading && visibleTenants.length === 0" description="暂无租户" :image-size="80" />
</div>
</template>
<style scoped lang="scss">
.position-page {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
box-sizing: border-box;
overflow-y: auto;
}
// 租户筛选栏
.tenant-filter {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.tenant-filter-label {
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
// 租户大容器
.tenant-container {
flex-shrink: 0;
.tenant-container-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
.tenant-title-dot {
width: 4px;
height: 16px;
border-radius: 2px;
background: var(--el-color-primary);
}
}
// 租户容器内按通用部门分组的子容器
.category-container {
margin-left: 12px;
margin-bottom: 14px;
.category-container-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: var(--el-text-color-regular);
&::before {
content: '';
width: 3px;
height: 12px;
border-radius: 2px;
background: var(--el-color-primary-light-5);
}
}
}
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<script setup lang="ts">
/**
* 通用部门管理(PositionCategory)——职务分类目录
*
* 租户级内置分类(如 财务=FIN / 行政=ADM / 运维=OPS / 人事=HR / 通用=GEN),
* 每个通用部门有编码缩写;职务绑定通用部门后编码自动按"缩写+序号"生成(如 FIN01)。
*
* 展示约定:
* - 按租户分组:每个租户一个"大容器"(标题=租户名),容器内是该租户的通用部门卡片
* - 普通用户/租户管理员:TenantSearch 仅返回自己租户 → 只显示一个容器
* - 跨租户超管:显示全部租户容器,各容器内新增/编辑/删除互不影响
* - 新增自动归属容器租户(CrudTable query 注入 TenantUUID,无需手动选租户)
*/
import { ref, computed, onMounted } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import { get } from '@/api/request'
import { setPageTitle } from '@/utils/serverScope'
setPageTitle('通用部门')
interface TenantOption {
TenantUUID: string
TenantName: string
}
const tenantOptions = ref<TenantOption[]>([])
const selectedTenant = ref('')
const tenantLoading = ref(false)
// 租户筛选:选中时仅显示该租户容器,清除则显示全部
const visibleTenants = computed<TenantOption[]>(() =>
selectedTenant.value
? tenantOptions.value.filter((t) => t.TenantUUID === selectedTenant.value)
: tenantOptions.value,
)
// 拉取租户列表(TenantSearch 每页 20 条,循环拉全量;普通用户仅返回自己的租户)
async function loadTenants(): Promise<void> {
tenantLoading.value = true
try {
const all: TenantOption[] = []
let offset = 0
for (;;) {
const res = await get<{ list: TenantOption[]; has_more?: boolean }>('/API/FormAPI/TenantSearch', { offset })
const list = res?.list || []
all.push(...list)
if (!res?.has_more || list.length === 0) break
offset += list.length
}
tenantOptions.value = all
} catch {
// 错误提示已由响应拦截器统一弹出
} finally {
tenantLoading.value = false
}
}
onMounted(loadTenants)
</script>
<template>
<div class="position-category-page" v-loading="tenantLoading">
<!-- 租户筛选 -->
<div class="tenant-filter">
<span class="tenant-filter-label">租户</span>
<el-select
v-model="selectedTenant"
placeholder="全部租户"
clearable
filterable
:loading="tenantLoading"
style="width: 240px"
>
<el-option
v-for="t in tenantOptions"
:key="t.TenantUUID"
:label="t.TenantName"
:value="t.TenantUUID"
/>
</el-select>
</div>
<!-- 按租户分组:每个租户一个大容器 -->
<div
v-for="tenant in visibleTenants"
:key="tenant.TenantUUID"
class="tenant-container"
>
<div class="tenant-container-title">
<span class="tenant-title-dot" />
<span class="tenant-title-text">{{ tenant.TenantName }}</span>
</div>
<CrudTable
table="PositionCategory"
:title="`${tenant.TenantName} · 通用部门`"
pk-field="PositionCategoryUUID"
:query="{ TenantUUID: tenant.TenantUUID }"
searchable
stretch
selectable
card-mode
card-title-field="CategoryName"
:card-fields="['CategoryCode', 'CategoryStatus', 'CategorySort', 'CreateTime']"
:card-value-maps="{
CategoryStatus: { 0: '禁用', 1: '启用' },
}"
/>
</div>
<el-empty v-if="!tenantLoading && visibleTenants.length === 0" description="暂无租户" :image-size="80" />
</div>
</template>
<style scoped lang="scss">
.position-category-page {
padding: 16px;
height: 100%;
display: flex;
flex-direction: column;
gap: 16px;
box-sizing: border-box;
overflow-y: auto;
}
// 租户筛选栏
.tenant-filter {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
.tenant-filter-label {
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
// 租户大容器:标题栏 + 卡片网格
.tenant-container {
flex-shrink: 0;
.tenant-container-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
.tenant-title-dot {
width: 4px;
height: 16px;
border-radius: 2px;
background: var(--el-color-primary);
}
}
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 公告通知(OA 办公自动化流程)
*
* 公告标题 / 内容 / 发布人 / 状态(草稿/已发布)。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('公告通知')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="oa-page">
<AdminBadge />
<CrudTable
table="OaAnnouncement"
title="公告通知"
pk-field="OaAnnouncementUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.oa-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+206
View File
@@ -0,0 +1,206 @@
<script setup lang="ts">
/**
* OA 审批页(行政人员专用)
*
* 展示当前用户作为审批人的待审批申请(请假/出差),支持通过/驳回。
* 仅绑定了行政类虚拟职务(行政专员/主管/经理)的用户可见此菜单。
*/
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import AdminBadge from '@/components/AdminBadge.vue'
import { oaApi, type OaApprovalItem } from '@/api/oa'
import { setPageTitle } from '@/utils/serverScope'
import { formatUtcTime as formatTime } from '@/utils/dateTime'
setPageTitle('审批')
/** 当前激活的申请类型 tab */
const activeType = ref<'leave' | 'travel'>('leave')
const loading = ref(false)
const submitting = ref(false)
/** 待审批数据:{ leave: [...], travel: [...] } */
const leaveList = ref<OaApprovalItem[]>([])
const travelList = ref<OaApprovalItem[]>([])
async function loadPending(): Promise<void> {
loading.value = true
try {
const r = await oaApi.pendingApprovals('all')
leaveList.value = r.leave || []
travelList.value = r.travel || []
} catch {
// 拦截器已提示
} finally {
loading.value = false
}
}
/** 通过 / 驳回(Remark 可选) */
async function handleApprove(row: OaApprovalItem, approved: boolean): Promise<void> {
if (submitting.value) return
try {
if (approved) {
await ElMessageBox.confirm(`确认通过该申请?`, '审批确认', {
type: 'warning',
confirmButtonText: '通过',
cancelButtonText: '取消',
})
} else {
const { value } = await ElMessageBox.prompt('请输入驳回原因(可选)', '驳回申请', {
type: 'warning',
confirmButtonText: '驳回',
cancelButtonText: '取消',
inputPlaceholder: '驳回原因',
})
if (value == null) return
submitting.value = true
await oaApi.approve(activeType.value, row.UUID, false, String(value || '').trim())
ElMessage.success('已驳回')
await loadPending()
return
}
submitting.value = true
await oaApi.approve(activeType.value, row.UUID, true)
ElMessage.success('审批通过')
await loadPending()
} catch (e) {
if (e === 'cancel' || (e as { message?: string })?.message === 'cancel') return
// 拦截器已提示接口错误
} finally {
submitting.value = false
}
}
let timer: ReturnType<typeof setInterval> | null = null
onMounted(() => {
void loadPending()
// 30s 轮询刷新待审批列表
timer = setInterval(() => void loadPending(), 30000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<div class="oa-approval-page">
<AdminBadge />
<el-card shadow="never">
<template #header>
<div class="approval-header">
<el-icon><Stamp /></el-icon>
<span>待我审批</span>
<span class="approval-sub">以下为我作为审批人的待处理申请(每 30 秒自动刷新)</span>
<div class="spacer" />
<el-button size="small" :loading="loading" @click="loadPending">刷新</el-button>
</div>
</template>
<el-tabs v-model="activeType">
<el-tab-pane label="请假申请" name="leave">
<el-table v-loading="loading" :data="leaveList" stripe>
<el-table-column label="申请人" prop="UserName" min-width="120" />
<el-table-column label="开始时间" min-width="160">
<template #default="{ row }">{{ formatTime(row.StartTime, false) }}</template>
</el-table-column>
<el-table-column label="结束时间" min-width="160">
<template #default="{ row }">{{ formatTime(row.EndTime, false) }}</template>
</el-table-column>
<el-table-column label="天数" prop="Days" width="70" />
<el-table-column label="事由" prop="Reason" min-width="200" show-overflow-tooltip />
<el-table-column label="提交时间" min-width="160">
<template #default="{ row }">{{ formatTime(row.CreateTime, false) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button
size="small"
type="primary"
:loading="submitting"
@click="handleApprove(row as OaApprovalItem, true)"
>
通过
</el-button>
<el-button
size="small"
type="danger"
plain
:loading="submitting"
@click="handleApprove(row as OaApprovalItem, false)"
>
驳回
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无待审批的请假申请" :image-size="60" />
</template>
</el-table>
</el-tab-pane>
<el-tab-pane label="出差申请" name="travel">
<el-table v-loading="loading" :data="travelList" stripe>
<el-table-column label="申请人" prop="UserName" min-width="120" />
<el-table-column label="开始时间" min-width="160">
<template #default="{ row }">{{ formatTime(row.StartTime, false) }}</template>
</el-table-column>
<el-table-column label="结束时间" min-width="160">
<template #default="{ row }">{{ formatTime(row.EndTime, false) }}</template>
</el-table-column>
<el-table-column label="天数" prop="Days" width="70" />
<el-table-column label="事由" prop="Reason" min-width="200" show-overflow-tooltip />
<el-table-column label="提交时间" min-width="160">
<template #default="{ row }">{{ formatTime(row.CreateTime, false) }}</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button
size="small"
type="primary"
:loading="submitting"
@click="handleApprove(row as OaApprovalItem, true)"
>
通过
</el-button>
<el-button
size="small"
type="danger"
plain
:loading="submitting"
@click="handleApprove(row as OaApprovalItem, false)"
>
驳回
</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="暂无待审批的出差申请" :image-size="60" />
</template>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
</template>
<style scoped lang="scss">
.oa-approval-page {
padding: 20px;
height: 100%;
box-sizing: border-box;
}
.approval-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.approval-sub {
font-weight: 400;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.spacer {
flex: 1;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 请假申请(OA 办公自动化流程)
*
* 请假类型 / 起止时间 / 天数 / 事由 / 审批人 / 状态(草稿/待审批/已通过/已驳回)。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('请假申请')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="oa-page">
<AdminBadge />
<CrudTable
table="OaLeave"
title="请假申请"
pk-field="OaLeaveUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.oa-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 会议管理(OA 办公自动化流程)
*
* 会议主题 / 地点 / 起止时间 / 主持人 / 参会人 / 会议纪要。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('会议管理')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="oa-page">
<AdminBadge />
<CrudTable
table="OaMeeting"
title="会议管理"
pk-field="OaMeetingUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.oa-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 工作汇报(OA 办公自动化流程)
*
* 日报/周报/月报:汇报期间 / 汇报内容。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('工作汇报')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="oa-page">
<AdminBadge />
<CrudTable
table="OaReport"
title="工作汇报"
pk-field="OaReportUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.oa-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
/**
* 出差申请(OA 办公自动化流程)
*
* 目的地 / 起止时间 / 天数 / 事由 / 审批人 / 状态(草稿/待审批/已通过/已驳回)。
*/
import { computed } from 'vue'
import CrudTable from '@/components/CrudTable.vue'
import AdminBadge from '@/components/AdminBadge.vue'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
const userStore = useUserStore()
setPageTitle('出差申请')
const isAdmin = computed(() => userStore.isAdmin)
</script>
<template>
<div class="oa-page">
<AdminBadge />
<CrudTable
table="OaTravel"
title="出差申请"
pk-field="OaTravelUUID"
searchable
pageable
stretch
:selectable="isAdmin"
/>
</div>
</template>
<style scoped lang="scss">
.oa-page {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
</style>
+149 -49
View File
@@ -5,13 +5,16 @@
* 迁移自 TempLate/GERP/pages/system/user-detail.html
*
* 业务结构:
* 1. 基本信息(InfoGrid):用户名 / 邮箱 / 邮箱验证 / 电话 / 性别 / 账号状态 / 职位状态 / 是否员工 / 是否超级用户 / 创建时间 / 最后登录
* 2. 2FA 双因素信息卡(TODO:GetUser2FAInfo 接口契约确认后补全字段渲染)
* 1. 基本信息(头像 + InfoGrid):用户名 / 真实姓名 / 邮箱 / 邮箱验证 / 电话 / 性别 / 账号状态 / 职位状态 / 是否员工 / 是否超级用户 / 创建时间 / 最后登录
* 2. 2FA 双因素信息卡(IsEnabled / Verified / LastUsedAt / CreateTime)
* 3. 角色与组织子表(CrudTable: UserRole,按 UserUUID 过滤)
* 4. 部门关联子表(CrudTable: UserDept,按 UserUUID 过滤)
* 4. 部门职务子表(CrudTable: UserRoleDepartment,按 UserUUID 反查该用户部门挂靠并设置职务)
* 5. 隔离提示横幅:管理员特权(跨公司)vs 仅当前公司
* 6. 编辑按钮:基于列定义的动态编辑弹窗(formApi.update)
*
* 注:本页为公司"员工详情"(employee-detail) 与系统"用户详情"的合并页
* (统一用户详情入口,原 /company/employee-detail 路由重定向至此)。
*
* 迁移要点(对齐工程迁移规则):
* - server / admin_verify 由 request.ts 拦截器统一注入,不再手动拼 buildApiUrl
* - 主表单条加载:formApi.list('User', { UserUUID, pageSize: 1 }) 取首条
@@ -30,7 +33,7 @@ import EntityPicker from '@/components/EntityPicker.vue'
import { formApi } from '@/api/form'
import { get } from '@/api/request'
import { useUserStore } from '@/stores/user'
import { setPageTitle } from '@/utils/serverScope'
import { getServerScopeParams, setPageTitle } from '@/utils/serverScope'
import { formatUtcTime as formatTime } from '@/utils/dateTime'
import type { ColumnSchema } from '@/types/api'
@@ -58,6 +61,16 @@ const PK_FIELD = 'UserUUID'
// 页面标题:有数据时显示用户名
const title = computed(() => data.value?.UserName || '用户详情')
// 头像 URL:手动附加 server / admin_verify 作用域参数(el-avatar 直接以 img 请求,不走 axios 拦截器)
const avatarUrl = computed(() => {
const params = new URLSearchParams()
params.set('UserUUID', userUUID.value)
const scope = getServerScopeParams()
if (scope.server) params.set('server', scope.server)
if (scope.admin_verify) params.set('admin_verify', scope.admin_verify)
return `/Query/GetELTUserAvatar?${params.toString()}`
})
// 隔离提示:管理员跨公司 vs 仅当前公司
// TODO: 接入 checkAdmin → role_scope,补 dept_manager / workgroup_lead 分支
const isolationHint = computed(() =>
@@ -193,11 +206,6 @@ function goRoleDetail(row: Record<string, any>): void {
router.push({ path: '/system/role-detail', query: { RoleUUID: row.RoleUUID || '' } })
}
/** UserDept 子表查看 → 部门详情 */
function goDeptDetail(row: Record<string, any>): void {
router.push({ path: '/company/department-detail', query: { DeptUUID: row.DeptUUID || '' } })
}
onMounted(async () => {
await Promise.all([loadData(), loadColumns()])
void loadTwoFactor()
@@ -232,42 +240,59 @@ watch(userUUID, () => {
/>
<template v-else>
<!-- 基本信息 -->
<InfoGrid title="基本信息" :columns="2">
<el-descriptions-item label="用户名">{{ data?.UserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ data?.UserEmail || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ data?.UserTele || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱验证">
<el-tag :type="emailVerifyTag(data?.UserEmailVerify).type" size="small">
{{ emailVerifyTag(data?.UserEmailVerify).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(data?.UserGender) }}</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="accountLicenseTag(data?.UserAccountLicense).type" size="small">
{{ accountLicenseTag(data?.UserAccountLicense).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="职位状态">
<el-tag :type="positionStatusTag(data?.UserPositionStatus).type" size="small">
{{ positionStatusTag(data?.UserPositionStatus).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否员工">{{ yesNoText(data?.IsStaff) }}</el-descriptions-item>
<el-descriptions-item label="是否超级用户">{{ yesNoText(data?.IsSuperuser) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(data?.UserCreateTime, false) }}</el-descriptions-item>
<el-descriptions-item label="最后登录">{{ formatTime(data?.UserLastLoginDate, false) }}</el-descriptions-item>
</InfoGrid>
<!-- 基本信息(头像 + 信息网格横向排列) -->
<div class="profile-row">
<!-- 头像:失败时显示默认图标 -->
<el-avatar :size="96" :src="avatarUrl" class="profile-avatar">
<el-icon :size="32"><User /></el-icon>
</el-avatar>
<InfoGrid title="基本信息" :columns="2">
<el-descriptions-item label="用户名">{{ data?.UserName || '-' }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ data?.UserRealName || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱">{{ data?.UserEmail || '-' }}</el-descriptions-item>
<el-descriptions-item label="电话">{{ data?.UserTele || '-' }}</el-descriptions-item>
<el-descriptions-item label="邮箱验证">
<el-tag :type="emailVerifyTag(data?.UserEmailVerify).type" size="small">
{{ emailVerifyTag(data?.UserEmailVerify).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(data?.UserGender) }}</el-descriptions-item>
<el-descriptions-item label="账号状态">
<el-tag :type="accountLicenseTag(data?.UserAccountLicense).type" size="small">
{{ accountLicenseTag(data?.UserAccountLicense).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="职位状态">
<el-tag :type="positionStatusTag(data?.UserPositionStatus).type" size="small">
{{ positionStatusTag(data?.UserPositionStatus).text }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="是否员工">{{ yesNoText(data?.IsStaff) }}</el-descriptions-item>
<el-descriptions-item label="是否超级用户">{{ yesNoText(data?.IsSuperuser) }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatTime(data?.UserCreateTime, false) }}</el-descriptions-item>
<el-descriptions-item label="最后登录">{{ formatTime(data?.UserLastLoginDate, false) }}</el-descriptions-item>
</InfoGrid>
</div>
<!-- 2FA 双因素信息(TODO:契约确认后补全字段) -->
<InfoGrid title="双因素认证 (2FA)" :columns="2">
<el-descriptions-item label="2FA 状态">
<!-- TODO: GetUser2FAInfo 字段契约确认后替换为真实状态渲染 -->
<el-tag v-if="twoFactorInfo" type="success" size="small">已加载</el-tag>
<el-tag v-else type="info" size="small">未启用 / 未加载</el-tag>
<!-- 安全信息(2FA) -->
<InfoGrid title="安全信息" :columns="2">
<el-descriptions-item label="2FA状态">
<span v-if="twoFactorInfo" :style="{ color: twoFactorInfo.IsEnabled === 1 ? '#166534' : '#991b1b' }">
{{ twoFactorInfo.IsEnabled === 1 ? '已启用' : '未启用' }}
</span>
<span v-else style="color: #6b7280">未配置</span>
</el-descriptions-item>
<el-descriptions-item label="说明">
<span style="color: #909399; font-size: 12px">2FA 详情字段待 GetUser2FAInfo 契约确认后接入</span>
<el-descriptions-item label="验证状态">
<span v-if="twoFactorInfo" :style="{ color: twoFactorInfo.Verified === 1 ? '#166534' : '#991b1b' }">
{{ twoFactorInfo.Verified === 1 ? '已验证' : '未验证' }}
</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="最后使用">
{{ twoFactorInfo && twoFactorInfo.LastUsedAt ? formatTime(twoFactorInfo.LastUsedAt) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="2FA创建时间">
{{ twoFactorInfo && twoFactorInfo.CreateTime ? formatTime(twoFactorInfo.CreateTime) : '-' }}
</el-descriptions-item>
</InfoGrid>
@@ -292,24 +317,81 @@ watch(userUUID, () => {
/>
</el-card>
<!-- 部门关联子表 -->
<!-- 部门职务子表(UserRoleDepartment:员工-角色对 挂靠部门 + 职务) -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Share /></el-icon>
<span>部门关联</span>
<span>部门职务</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="UserDept"
title="用户部门"
table="UserRoleDepartment"
title="部门职务"
:query="{ UserUUID: userUUID }"
:pk-field="'UserDeptUUID'"
pk-field="UserRoleDepartmentUUID"
selectable
pageable
:max-height="400"
/>
</el-card>
<!-- eHRM 联动子表:员工档案 / 考核 / 绩效(query 中的 UserUUID 自动注入新增/编辑表单) -->
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Document /></el-icon>
<span>员工档案(eHRM)</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="EmployeeProfile"
title="员工档案"
:query="{ UserUUID: userUUID }"
pk-field="EmployeeProfileUUID"
selectable
pageable
:max-height="400"
/>
</el-card>
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><Aim /></el-icon>
<span>员工考核(eHRM)</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="EmployeeAssessment"
title="员工考核"
:query="{ UserUUID: userUUID }"
pk-field="AssessmentUUID"
selectable
pageable
:max-height="400"
/>
</el-card>
<el-card shadow="never" class="detail-card">
<template #header>
<div class="detail-card-header">
<el-icon><TrendCharts /></el-icon>
<span>员工绩效(eHRM)</span>
</div>
</template>
<CrudTable
v-if="userUUID"
table="EmployeePerformance"
title="员工绩效"
:query="{ UserUUID: userUUID }"
pk-field="PerformanceUUID"
selectable
pageable
:max-height="400"
:on-view="goDeptDetail"
/>
</el-card>
</template>
@@ -404,4 +486,22 @@ watch(userUUID, () => {
<style scoped lang="scss">
// el-card 之间留白由 DetailPage 的 detail-content gap 控制
// 头像 + 信息网格横向排列
.profile-row {
display: flex;
gap: 20px;
align-items: flex-start;
// 基本信息卡片撑满头像右侧剩余宽度
.info-grid-card {
flex: 1;
min-width: 0;
}
}
.profile-avatar {
flex-shrink: 0;
background: var(--el-fill-color-light);
}
</style>