Merge branch 'master' of https://gitea.xinyuxi.com/zhanggaoming/xh-medical-sign-web
This commit is contained in:
@@ -84,7 +84,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
|
|||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
|
|
||||||
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台和文档库管理页面。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
|
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台和文档库管理页面。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。组织架构页按机构→院区→科室分组展示员工列表,新增院区默认归属“新华院区”,支持节点筛选、员工维护和组织节点维护。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
|
||||||
|
|
||||||
文档库管理的真实模式已打通「模板 → 版本 → 正文」三段:
|
文档库管理的真实模式已打通「模板 → 版本 → 正文」三段:
|
||||||
|
|
||||||
|
|||||||
@@ -20,26 +20,35 @@ function getRecords(data: BackendCollection<OrganizationResponseDto>) {
|
|||||||
return Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
|
return Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCampuses(): Promise<CampusRecord[]> {
|
export async function getCampuses(): Promise<CampusRecord[]> {
|
||||||
if (useMockData) {
|
if (useMockData) {
|
||||||
return Promise.resolve([])
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
return request
|
const firstPage = await getCampusPage(1, 200)
|
||||||
.get<ApiResponse<BackendCollection<OrganizationResponseDto>>>('/v1/campuses', {
|
const pageCount = Math.ceil(firstPage.total / firstPage.pageSize)
|
||||||
params: { page: 1, size: 200 },
|
if (pageCount <= 1) return firstPage.records
|
||||||
})
|
|
||||||
.then(unwrapApiResponse)
|
const remainingPages = await Promise.all(
|
||||||
.then((data) =>
|
Array.from({ length: pageCount - 1 }, (_, index) =>
|
||||||
getRecords(data).map((item) => ({
|
getCampusPage(index + 2, firstPage.pageSize),
|
||||||
id: item.id,
|
),
|
||||||
code: item.code ?? item.id,
|
)
|
||||||
name: item.name ?? item.campusName ?? item.id,
|
return [firstPage, ...remainingPages].flatMap((page) => page.records)
|
||||||
address: item.address ?? undefined,
|
}
|
||||||
status: item.status ?? 'ENABLED',
|
|
||||||
sortNo: item.sortNo ?? 0,
|
async function getCampusPage(page: number, pageSize: number) {
|
||||||
})),
|
const response = await request.get<ApiResponse<BackendCollection<OrganizationResponseDto>>>(
|
||||||
)
|
'/v1/campuses',
|
||||||
|
{ params: { page, size: pageSize } },
|
||||||
|
)
|
||||||
|
const data = unwrapApiResponse(response)
|
||||||
|
const records = getRecords(data).map(mapCampus)
|
||||||
|
return {
|
||||||
|
records,
|
||||||
|
total: Array.isArray(data) ? records.length : (data.total ?? records.length),
|
||||||
|
pageSize: Array.isArray(data) ? pageSize : (data.size ?? pageSize),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getDepartments(campusId?: string): Promise<DepartmentRecord[]> {
|
export function getDepartments(campusId?: string): Promise<DepartmentRecord[]> {
|
||||||
@@ -102,6 +111,7 @@ function mapCampus(data: OrganizationResponseDto): CampusRecord {
|
|||||||
id: data.id,
|
id: data.id,
|
||||||
code: data.code ?? data.id,
|
code: data.code ?? data.id,
|
||||||
name: data.name ?? data.campusName ?? data.id,
|
name: data.name ?? data.campusName ?? data.id,
|
||||||
|
institutionName: data.institutionName?.trim() || '新华院区',
|
||||||
address: data.address ?? undefined,
|
address: data.address ?? undefined,
|
||||||
status: data.status ?? 'ENABLED',
|
status: data.status ?? 'ENABLED',
|
||||||
sortNo: data.sortNo ?? 0,
|
sortNo: data.sortNo ?? 0,
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ export interface CampusRecord {
|
|||||||
id: string
|
id: string
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
|
institutionName: string
|
||||||
address?: string
|
address?: string
|
||||||
status: OrganizationStatus
|
status: OrganizationStatus
|
||||||
sortNo: number
|
sortNo: number
|
||||||
@@ -263,6 +264,7 @@ export type OrganizationStatus = 'ENABLED' | 'DISABLED' | string
|
|||||||
export interface CreateCampusRequest {
|
export interface CreateCampusRequest {
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
|
institutionName?: string
|
||||||
address?: string
|
address?: string
|
||||||
status?: OrganizationStatus
|
status?: OrganizationStatus
|
||||||
sortNo?: number
|
sortNo?: number
|
||||||
@@ -285,6 +287,7 @@ export interface OrganizationResponseDto {
|
|||||||
id: string
|
id: string
|
||||||
code?: string | null
|
code?: string | null
|
||||||
name?: string | null
|
name?: string | null
|
||||||
|
institutionName?: string | null
|
||||||
address?: string | null
|
address?: string | null
|
||||||
status?: OrganizationStatus | null
|
status?: OrganizationStatus | null
|
||||||
sortNo?: number | null
|
sortNo?: number | null
|
||||||
@@ -431,12 +434,14 @@ export type UserStatus = 'enabled' | 'disabled' | 'locked'
|
|||||||
export interface UserQuery extends PageQuery {
|
export interface UserQuery extends PageQuery {
|
||||||
keyword?: string
|
keyword?: string
|
||||||
department?: string
|
department?: string
|
||||||
|
departmentId?: string
|
||||||
status?: UserStatus | 'all'
|
status?: UserStatus | 'all'
|
||||||
campusId?: string
|
campusId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserRecord {
|
export interface UserRecord {
|
||||||
id: string
|
id: string
|
||||||
|
employeeId?: number | null
|
||||||
name: string
|
name: string
|
||||||
account: string
|
account: string
|
||||||
department: string
|
department: string
|
||||||
@@ -444,6 +449,8 @@ export interface UserRecord {
|
|||||||
status: UserStatus
|
status: UserStatus
|
||||||
lastLoginAt: string
|
lastLoginAt: string
|
||||||
employeeNo?: string | null
|
employeeNo?: string | null
|
||||||
|
position?: string | null
|
||||||
|
professionalTitle?: string | null
|
||||||
phone?: string | null
|
phone?: string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
campusId?: string | null
|
campusId?: string | null
|
||||||
@@ -452,6 +459,7 @@ export interface UserRecord {
|
|||||||
departmentName?: string
|
departmentName?: string
|
||||||
roleNames?: string[]
|
roleNames?: string[]
|
||||||
roleIds?: string[]
|
roleIds?: string[]
|
||||||
|
roleDataScopes?: RoleDataScopeType[]
|
||||||
version?: number
|
version?: number
|
||||||
passwordChangeRequired?: boolean
|
passwordChangeRequired?: boolean
|
||||||
}
|
}
|
||||||
@@ -460,9 +468,12 @@ export type UserListResponse = PageResult<UserRecord>
|
|||||||
|
|
||||||
export interface UserResponseDto {
|
export interface UserResponseDto {
|
||||||
id: string
|
id: string
|
||||||
|
employeeId?: number | null
|
||||||
username: string
|
username: string
|
||||||
displayName: string
|
displayName: string
|
||||||
employeeNo?: string | null
|
employeeNo?: string | null
|
||||||
|
position?: string | null
|
||||||
|
professionalTitle?: string | null
|
||||||
phone?: string | null
|
phone?: string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
campusId?: string | null
|
campusId?: string | null
|
||||||
@@ -483,6 +494,7 @@ export interface UserRoleSummaryDto {
|
|||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
status?: RoleStatus | null
|
status?: RoleStatus | null
|
||||||
|
dataScope?: RoleDataScopeType | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateUserRequest {
|
export interface CreateUserRequest {
|
||||||
@@ -490,11 +502,14 @@ export interface CreateUserRequest {
|
|||||||
password: string
|
password: string
|
||||||
displayName: string
|
displayName: string
|
||||||
employeeNo?: string
|
employeeNo?: string
|
||||||
|
position?: string
|
||||||
|
professionalTitle?: string
|
||||||
phone?: string
|
phone?: string
|
||||||
email?: string
|
email?: string
|
||||||
campusId: string
|
campusId: string
|
||||||
departmentId?: string | null
|
departmentId?: string | null
|
||||||
status?: BackendUserStatus
|
status?: BackendUserStatus
|
||||||
|
passwordChangeRequired?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackendUserStatus = 'ENABLED' | 'DISABLED' | 'LOCKED'
|
export type BackendUserStatus = 'ENABLED' | 'DISABLED' | 'LOCKED'
|
||||||
@@ -502,6 +517,8 @@ export type BackendUserStatus = 'ENABLED' | 'DISABLED' | 'LOCKED'
|
|||||||
export interface UpdateUserRequest {
|
export interface UpdateUserRequest {
|
||||||
displayName: string
|
displayName: string
|
||||||
employeeNo?: string
|
employeeNo?: string
|
||||||
|
position?: string
|
||||||
|
professionalTitle?: string
|
||||||
phone?: string
|
phone?: string
|
||||||
email?: string
|
email?: string
|
||||||
campusId: string
|
campusId: string
|
||||||
@@ -631,6 +648,7 @@ export interface UpdateDepartmentPermissionPolicyRequest {
|
|||||||
export interface PasswordResetRequest {
|
export interface PasswordResetRequest {
|
||||||
newPassword: string
|
newPassword: string
|
||||||
forceChangeOnNextLogin?: boolean
|
forceChangeOnNextLogin?: boolean
|
||||||
|
reason: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserImportRowStatus = 'VALID' | 'INVALID'
|
export type UserImportRowStatus = 'VALID' | 'INVALID'
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ function mapUser(dto: UserResponseDto): UserRecord {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: dto.id,
|
id: dto.id,
|
||||||
|
employeeId: dto.employeeId,
|
||||||
name: dto.displayName,
|
name: dto.displayName,
|
||||||
account: dto.username,
|
account: dto.username,
|
||||||
department: dto.departmentName ?? (dto.departmentId ? '未知科室' : '未指定科室'),
|
department: dto.departmentName ?? (dto.departmentId ? '未知科室' : '未指定科室'),
|
||||||
@@ -97,6 +98,8 @@ function mapUser(dto: UserResponseDto): UserRecord {
|
|||||||
status: normalizeStatus(dto.status),
|
status: normalizeStatus(dto.status),
|
||||||
lastLoginAt: formatDateTime(dto.lastLoginAt),
|
lastLoginAt: formatDateTime(dto.lastLoginAt),
|
||||||
employeeNo: dto.employeeNo,
|
employeeNo: dto.employeeNo,
|
||||||
|
position: dto.position,
|
||||||
|
professionalTitle: dto.professionalTitle,
|
||||||
phone: dto.phone,
|
phone: dto.phone,
|
||||||
email: dto.email,
|
email: dto.email,
|
||||||
campusId: dto.campusId,
|
campusId: dto.campusId,
|
||||||
@@ -105,6 +108,7 @@ function mapUser(dto: UserResponseDto): UserRecord {
|
|||||||
departmentName: dto.departmentName ?? undefined,
|
departmentName: dto.departmentName ?? undefined,
|
||||||
roleNames,
|
roleNames,
|
||||||
roleIds: roles.map((role) => role.id),
|
roleIds: roles.map((role) => role.id),
|
||||||
|
roleDataScopes: roles.map((role) => role.dataScope ?? 'SELF'),
|
||||||
version: dto.version ?? undefined,
|
version: dto.version ?? undefined,
|
||||||
passwordChangeRequired: dto.passwordChangeRequired,
|
passwordChangeRequired: dto.passwordChangeRequired,
|
||||||
}
|
}
|
||||||
@@ -139,6 +143,10 @@ function toBackendQuery(query: UserQuery) {
|
|||||||
params.campusId = query.campusId
|
params.campusId = query.campusId
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.departmentId) {
|
||||||
|
params.departmentId = query.departmentId
|
||||||
|
}
|
||||||
|
|
||||||
return params
|
return params
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,10 +171,13 @@ export function getUsers(query: UserQuery): Promise<UserListResponse> {
|
|||||||
const matchesKeyword =
|
const matchesKeyword =
|
||||||
!keyword || [record.name, record.account].join(' ').toLowerCase().includes(keyword)
|
!keyword || [record.name, record.account].join(' ').toLowerCase().includes(keyword)
|
||||||
const matchesDepartment = !query.department || record.department === query.department
|
const matchesDepartment = !query.department || record.department === query.department
|
||||||
|
const matchesDepartmentId = !query.departmentId || record.departmentId === query.departmentId
|
||||||
const matchesStatus = !query.status || query.status === 'all' || record.status === query.status
|
const matchesStatus = !query.status || query.status === 'all' || record.status === query.status
|
||||||
const matchesCampus = !query.campusId || record.campusId === query.campusId
|
const matchesCampus = !query.campusId || record.campusId === query.campusId
|
||||||
|
|
||||||
return matchesKeyword && matchesDepartment && matchesStatus && matchesCampus
|
return (
|
||||||
|
matchesKeyword && matchesDepartment && matchesDepartmentId && matchesStatus && matchesCampus
|
||||||
|
)
|
||||||
})
|
})
|
||||||
const page = Math.max(query.page, 1)
|
const page = Math.max(query.page, 1)
|
||||||
const pageSize = Math.max(query.pageSize, 1)
|
const pageSize = Math.max(query.pageSize, 1)
|
||||||
|
|||||||
@@ -10,9 +10,30 @@ import type { WorkbenchCampus } from '@/api/workbench/types'
|
|||||||
const useMockData = mockFlags.organization
|
const useMockData = mockFlags.organization
|
||||||
|
|
||||||
const mockCampusRecords: CampusRecord[] = [
|
const mockCampusRecords: CampusRecord[] = [
|
||||||
{ id: 'mock-campus-main', code: 'MAIN', name: '本部院区', status: 'ENABLED', sortNo: 1 },
|
{
|
||||||
{ id: 'mock-campus-east', code: 'EAST', name: '东院区', status: 'ENABLED', sortNo: 2 },
|
id: 'mock-campus-main',
|
||||||
{ id: 'mock-campus-west', code: 'WEST', name: '西院区', status: 'ENABLED', sortNo: 3 },
|
code: 'MAIN',
|
||||||
|
name: '本部院区',
|
||||||
|
institutionName: '新华院区',
|
||||||
|
status: 'ENABLED',
|
||||||
|
sortNo: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-campus-east',
|
||||||
|
code: 'EAST',
|
||||||
|
name: '东院区',
|
||||||
|
institutionName: '新华院区',
|
||||||
|
status: 'ENABLED',
|
||||||
|
sortNo: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mock-campus-west',
|
||||||
|
code: 'WEST',
|
||||||
|
name: '西院区',
|
||||||
|
institutionName: '新华院区',
|
||||||
|
status: 'ENABLED',
|
||||||
|
sortNo: 3,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function getEnabledCampuses(records: CampusRecord[]) {
|
function getEnabledCampuses(records: CampusRecord[]) {
|
||||||
|
|||||||
@@ -81,11 +81,19 @@ export interface LocalDepartment {
|
|||||||
* 演示 id 带 mock- 前缀,一眼能看出不是真实 UUID,不会误当成可提交给后端的取值。
|
* 演示 id 带 mock- 前缀,一眼能看出不是真实 UUID,不会误当成可提交给后端的取值。
|
||||||
*/
|
*/
|
||||||
export const demoCampuses = [
|
export const demoCampuses = [
|
||||||
{ id: 'mock-campus-main', code: 'MAIN', name: '本部院区', status: 'ENABLED' as const, sortNo: 1 },
|
{
|
||||||
|
id: 'mock-campus-main',
|
||||||
|
code: 'MAIN',
|
||||||
|
name: '本部院区',
|
||||||
|
institutionName: '新华院区',
|
||||||
|
status: 'ENABLED' as const,
|
||||||
|
sortNo: 1,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'mock-campus-east',
|
id: 'mock-campus-east',
|
||||||
code: 'EAST',
|
code: 'EAST',
|
||||||
name: '东院区',
|
name: '东院区',
|
||||||
|
institutionName: '新华院区',
|
||||||
status: 'ENABLED' as const,
|
status: 'ENABLED' as const,
|
||||||
sortNo: 2,
|
sortNo: 2,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, watch } from 'vue'
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CampusRecord,
|
||||||
|
CreateCampusRequest,
|
||||||
|
CreateDepartmentRequest,
|
||||||
|
DepartmentRecord,
|
||||||
|
} from '@/api/management/types'
|
||||||
|
|
||||||
|
export type OrganizationNodeSave =
|
||||||
|
| { kind: 'campus'; id?: string; payload: CreateCampusRequest }
|
||||||
|
| { kind: 'department'; id?: string; payload: CreateDepartmentRequest }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
visible: boolean
|
||||||
|
kind: 'campus' | 'department'
|
||||||
|
campuses: CampusRecord[]
|
||||||
|
departments: DepartmentRecord[]
|
||||||
|
initial: CampusRecord | DepartmentRecord | null
|
||||||
|
defaultCampusId: string
|
||||||
|
saving: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
save: [form: OrganizationNodeSave]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isEditing = computed(() => Boolean(props.initial))
|
||||||
|
const campusForm = reactive({
|
||||||
|
institutionName: '新华院区',
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
status: 'ENABLED',
|
||||||
|
sortNo: 0,
|
||||||
|
})
|
||||||
|
const departmentForm = reactive({
|
||||||
|
campusId: '',
|
||||||
|
parentId: '' as string | null,
|
||||||
|
code: '',
|
||||||
|
name: '',
|
||||||
|
status: 'ENABLED',
|
||||||
|
sortNo: 0,
|
||||||
|
})
|
||||||
|
const error = reactive({ message: '' })
|
||||||
|
const parentOptions = computed(() =>
|
||||||
|
props.departments.filter(
|
||||||
|
(department) =>
|
||||||
|
department.campusId === departmentForm.campusId && department.id !== props.initial?.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
error.message = ''
|
||||||
|
if (props.kind === 'campus') {
|
||||||
|
const initial = props.initial as CampusRecord | null
|
||||||
|
Object.assign(campusForm, {
|
||||||
|
institutionName: initial?.institutionName ?? '新华院区',
|
||||||
|
code: initial?.code ?? '',
|
||||||
|
name: initial?.name ?? '',
|
||||||
|
address: initial?.address ?? '',
|
||||||
|
status: initial?.status ?? 'ENABLED',
|
||||||
|
sortNo: initial?.sortNo ?? props.campuses.length + 1,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const initial = props.initial as DepartmentRecord | null
|
||||||
|
Object.assign(departmentForm, {
|
||||||
|
campusId: initial?.campusId ?? props.defaultCampusId ?? props.campuses[0]?.id ?? '',
|
||||||
|
parentId: initial?.parentId ?? null,
|
||||||
|
code: initial?.code ?? '',
|
||||||
|
name: initial?.name ?? '',
|
||||||
|
status: initial?.status ?? 'ENABLED',
|
||||||
|
sortNo: initial?.sortNo ?? props.departments.length + 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
error.message = ''
|
||||||
|
if (props.kind === 'campus') {
|
||||||
|
if (!campusForm.institutionName.trim() || !campusForm.name.trim() || !campusForm.code.trim()) {
|
||||||
|
error.message = '请填写所属机构、院区名称和编码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('save', {
|
||||||
|
kind: 'campus',
|
||||||
|
id: (props.initial as CampusRecord | null)?.id,
|
||||||
|
payload: {
|
||||||
|
institutionName: campusForm.institutionName.trim(),
|
||||||
|
code: campusForm.code.trim(),
|
||||||
|
name: campusForm.name.trim(),
|
||||||
|
address: campusForm.address.trim() || undefined,
|
||||||
|
status: campusForm.status,
|
||||||
|
sortNo: campusForm.sortNo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!departmentForm.campusId || !departmentForm.name.trim() || !departmentForm.code.trim()) {
|
||||||
|
error.message = '请填写院区、科室名称和编码'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
emit('save', {
|
||||||
|
kind: 'department',
|
||||||
|
id: (props.initial as DepartmentRecord | null)?.id,
|
||||||
|
payload: {
|
||||||
|
campusId: departmentForm.campusId,
|
||||||
|
parentId: departmentForm.parentId || null,
|
||||||
|
code: departmentForm.code.trim(),
|
||||||
|
name: departmentForm.name.trim(),
|
||||||
|
status: departmentForm.status,
|
||||||
|
sortNo: departmentForm.sortNo,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.kind, props.initial] as const,
|
||||||
|
([visible]) => {
|
||||||
|
if (visible) resetForm()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => departmentForm.campusId,
|
||||||
|
() => {
|
||||||
|
if (!parentOptions.value.some((department) => department.id === departmentForm.parentId)) {
|
||||||
|
departmentForm.parentId = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="node-mask" @click.self="emit('close')">
|
||||||
|
<section class="node-dialog" role="dialog" aria-modal="true">
|
||||||
|
<header class="node-dialog__header">
|
||||||
|
<div>
|
||||||
|
<strong
|
||||||
|
>{{ isEditing ? '编辑' : '新增' }}{{ kind === 'campus' ? '院区' : '科室' }}</strong
|
||||||
|
>
|
||||||
|
<p>组织信息用于维护员工归属和组织架构</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="node-dialog__close" aria-label="关闭" @click="emit('close')">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="node-dialog__body" @submit.prevent="submit">
|
||||||
|
<div class="node-section-title">
|
||||||
|
{{ kind === 'campus' ? '院区基本信息' : '科室基本信息' }}
|
||||||
|
</div>
|
||||||
|
<template v-if="kind === 'campus'">
|
||||||
|
<label class="node-dialog__full">
|
||||||
|
<span>所属机构 <em>*</em></span>
|
||||||
|
<input v-model="campusForm.institutionName" placeholder="如:新华院区" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>院区名称 <em>*</em></span>
|
||||||
|
<input v-model="campusForm.name" placeholder="如:本部院区" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>院区编码 <em>*</em></span>
|
||||||
|
<input v-model="campusForm.code" :disabled="isEditing" placeholder="如:MAIN" />
|
||||||
|
</label>
|
||||||
|
<label class="node-dialog__full">
|
||||||
|
<span>院区地址</span>
|
||||||
|
<input v-model="campusForm.address" placeholder="请输入院区地址" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>状态</span>
|
||||||
|
<select v-model="campusForm.status">
|
||||||
|
<option value="ENABLED">启用</option>
|
||||||
|
<option value="DISABLED">停用</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label>
|
||||||
|
<span>所属院区 <em>*</em></span>
|
||||||
|
<select v-model="departmentForm.campusId">
|
||||||
|
<option value="" disabled>请选择院区</option>
|
||||||
|
<option v-for="campus in campuses" :key="campus.id" :value="campus.id">
|
||||||
|
{{ campus.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>科室名称 <em>*</em></span>
|
||||||
|
<input v-model="departmentForm.name" placeholder="如:放射科" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>科室编码 <em>*</em></span>
|
||||||
|
<input v-model="departmentForm.code" :disabled="isEditing" placeholder="如:RAD" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>上级科室</span>
|
||||||
|
<select v-model="departmentForm.parentId">
|
||||||
|
<option :value="null">无</option>
|
||||||
|
<option
|
||||||
|
v-for="department in parentOptions"
|
||||||
|
:key="department.id"
|
||||||
|
:value="department.id"
|
||||||
|
>
|
||||||
|
{{ department.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>状态</span>
|
||||||
|
<select v-model="departmentForm.status">
|
||||||
|
<option value="ENABLED">启用</option>
|
||||||
|
<option value="DISABLED">停用</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</template>
|
||||||
|
<p v-if="error.message" class="node-dialog__error">{{ error.message }}</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<footer class="node-dialog__footer">
|
||||||
|
<span class="footer-spacer" />
|
||||||
|
<button type="button" class="node-button node-button--ghost" @click="emit('close')">
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" class="node-button" :disabled="saving" @click="submit">
|
||||||
|
{{
|
||||||
|
saving
|
||||||
|
? '保存中…'
|
||||||
|
: isEditing
|
||||||
|
? '保存变更'
|
||||||
|
: kind === 'campus'
|
||||||
|
? '创建院区'
|
||||||
|
: '创建科室'
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.node-mask {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 60;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: rgb(9 40 52 / 45%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog {
|
||||||
|
width: 660px;
|
||||||
|
max-width: 96vw;
|
||||||
|
margin: auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--sh-modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__header,
|
||||||
|
.node-dialog__footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__header {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__header strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__header p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__close {
|
||||||
|
padding: 0;
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 20px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__body {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__body label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__body label > span {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__body em {
|
||||||
|
color: var(--err);
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__body input,
|
||||||
|
.node-dialog__body select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 34px;
|
||||||
|
padding: 7px 9px;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__body input:focus,
|
||||||
|
.node-dialog__body select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-section-title,
|
||||||
|
.node-dialog__full,
|
||||||
|
.node-dialog__error {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-section-title {
|
||||||
|
padding: 9px 11px;
|
||||||
|
color: var(--brand-d);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f2f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__error {
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--err);
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--err-l);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dialog__footer {
|
||||||
|
background: #f8fbfc;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-button--ghost {
|
||||||
|
color: var(--brand);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.node-dialog__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,397 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { CampusRecord, DepartmentRecord } from '@/api/management/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
campuses: CampusRecord[]
|
||||||
|
departments: DepartmentRecord[]
|
||||||
|
selectedCampusId: string
|
||||||
|
selectedDepartmentId: string | null
|
||||||
|
collapsed: boolean
|
||||||
|
canManageCampuses: boolean
|
||||||
|
canManageDepartments: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
selectCampus: [id: string]
|
||||||
|
selectDepartment: [department: DepartmentRecord]
|
||||||
|
addCampus: []
|
||||||
|
addDepartment: []
|
||||||
|
editCampus: [campus: CampusRecord]
|
||||||
|
editDepartment: [department: DepartmentRecord]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const institutionGroups = computed(() => {
|
||||||
|
const grouped = new Map<string, CampusRecord[]>()
|
||||||
|
for (const campus of props.campuses) {
|
||||||
|
const institutionName = campus.institutionName?.trim() || '新华院区'
|
||||||
|
const group = grouped.get(institutionName) ?? []
|
||||||
|
group.push(campus)
|
||||||
|
grouped.set(institutionName, group)
|
||||||
|
}
|
||||||
|
return [...grouped].map(([name, campuses]) => ({ name, campuses }))
|
||||||
|
})
|
||||||
|
|
||||||
|
function departmentsForCampus(campusId: string) {
|
||||||
|
return props.departments
|
||||||
|
.filter((department) => department.campusId === campusId)
|
||||||
|
.sort(
|
||||||
|
(left, right) => left.sortNo - right.sortNo || left.name.localeCompare(right.name, 'zh-CN'),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function departmentCountForInstitution(campuses: CampusRecord[]) {
|
||||||
|
const campusIds = new Set(campuses.map((campus) => campus.id))
|
||||||
|
return props.departments.filter((department) => campusIds.has(department.campusId)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
function childrenFor(parentId: string | null | undefined, campusId: string) {
|
||||||
|
return departmentsForCampus(campusId).filter(
|
||||||
|
(department) => (department.parentId ?? null) === (parentId ?? null),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDescendantSelected(department: DepartmentRecord): boolean {
|
||||||
|
return props.selectedDepartmentId === department.id
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="org-card">
|
||||||
|
<header class="org-card__heading">
|
||||||
|
<strong>架构树</strong>
|
||||||
|
<span class="heading-spacer" />
|
||||||
|
<button
|
||||||
|
v-if="canManageCampuses"
|
||||||
|
type="button"
|
||||||
|
class="org-tool-button"
|
||||||
|
@click="emit('addCampus')"
|
||||||
|
>
|
||||||
|
+新增院区
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canManageDepartments"
|
||||||
|
type="button"
|
||||||
|
class="org-tool-button"
|
||||||
|
@click="emit('addDepartment')"
|
||||||
|
>
|
||||||
|
+新增科室
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="org-tree">
|
||||||
|
<div
|
||||||
|
v-for="institution in institutionGroups"
|
||||||
|
:key="institution.name"
|
||||||
|
class="org-tree__institution-group"
|
||||||
|
>
|
||||||
|
<div class="org-tree__root">
|
||||||
|
<span class="org-tree__root-icon">🏛</span>
|
||||||
|
<span class="org-tree__name">{{ institution.name }}</span>
|
||||||
|
<span class="org-tree__root-count">
|
||||||
|
{{ institution.campuses.length }} 院区 /
|
||||||
|
{{ departmentCountForInstitution(institution.campuses) }} 科室
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="campus in institution.campuses" :key="campus.id" class="org-tree__campus-group">
|
||||||
|
<div
|
||||||
|
class="org-tree__node org-tree__campus"
|
||||||
|
:class="{ 'is-selected': selectedCampusId === campus.id && !selectedDepartmentId }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="org-tree__pick"
|
||||||
|
:aria-expanded="selectedCampusId === campus.id && !collapsed"
|
||||||
|
@click="emit('selectCampus', campus.id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="org-tree__fold"
|
||||||
|
:class="{ 'is-open': selectedCampusId === campus.id && !collapsed }"
|
||||||
|
>▸</span
|
||||||
|
>
|
||||||
|
<span class="org-tree__icon">🏥</span>
|
||||||
|
<span class="org-tree__name">{{ campus.name }}</span>
|
||||||
|
<span class="org-tree__count">{{ departmentsForCampus(campus.id).length }} 科室</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canManageCampuses"
|
||||||
|
type="button"
|
||||||
|
class="org-tree__edit"
|
||||||
|
:aria-label="`编辑院区${campus.name}`"
|
||||||
|
@click="emit('editCampus', campus)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedCampusId === campus.id && !collapsed"
|
||||||
|
class="org-tree__department-list"
|
||||||
|
>
|
||||||
|
<template v-for="department in childrenFor(null, campus.id)" :key="department.id">
|
||||||
|
<div
|
||||||
|
class="org-tree__node org-tree__department"
|
||||||
|
:class="{ 'is-selected': isDescendantSelected(department) }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="org-tree__pick"
|
||||||
|
@click="emit('selectDepartment', department)"
|
||||||
|
>
|
||||||
|
<span class="org-tree__name">🗂 {{ department.name }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canManageDepartments"
|
||||||
|
type="button"
|
||||||
|
class="org-tree__edit"
|
||||||
|
:aria-label="`编辑科室${department.name}`"
|
||||||
|
@click="emit('editDepartment', department)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="child in childrenFor(department.id, campus.id)"
|
||||||
|
:key="child.id"
|
||||||
|
class="org-tree__node org-tree__child-department"
|
||||||
|
:class="{ 'is-selected': isDescendantSelected(child) }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="org-tree__pick"
|
||||||
|
@click="emit('selectDepartment', child)"
|
||||||
|
>
|
||||||
|
<span class="org-tree__name">{{ child.name }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="canManageDepartments"
|
||||||
|
type="button"
|
||||||
|
class="org-tree__edit"
|
||||||
|
:aria-label="`编辑科室${child.name}`"
|
||||||
|
@click="emit('editDepartment', child)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<p v-if="!childrenFor(null, campus.id).length" class="org-tree__empty">
|
||||||
|
该院区还没有维护科室
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="!campuses.length" class="org-tree__empty">暂无可用院区</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.org-card {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: var(--sh-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-card__heading {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #eef4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-card__heading strong {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 11px;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-card__heading strong::before {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 0;
|
||||||
|
width: 3px;
|
||||||
|
height: 14px;
|
||||||
|
content: '';
|
||||||
|
background: var(--brand);
|
||||||
|
border-radius: 2px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tool-button {
|
||||||
|
padding: 4px 7px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tool-button:hover {
|
||||||
|
background: var(--brand-l);
|
||||||
|
border-color: #d3e7ee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 1px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__root,
|
||||||
|
.org-tree__node,
|
||||||
|
.org-tree__pick {
|
||||||
|
display: flex;
|
||||||
|
gap: 9px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__root {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: var(--brand-d);
|
||||||
|
background: linear-gradient(135deg, var(--brand-l), #f5fbfd);
|
||||||
|
border: 1px solid #d3e7ee;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 1px 6px rgb(13 62 81 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__root-icon,
|
||||||
|
.org-tree__icon {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 28px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 9px;
|
||||||
|
box-shadow: 0 1px 4px rgb(13 62 81 / 10%);
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__root-count,
|
||||||
|
.org-tree__count {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 2px 8px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d3e7ee;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__node {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 9px;
|
||||||
|
transition:
|
||||||
|
background 0.14s,
|
||||||
|
box-shadow 0.14s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__node:hover {
|
||||||
|
background: #f4fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__node.is-selected {
|
||||||
|
color: var(--brand-d);
|
||||||
|
background: var(--brand-l);
|
||||||
|
box-shadow: inset 3px 0 0 var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__pick {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__campus .org-tree__pick {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__fold {
|
||||||
|
flex: 0 0 12px;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 10px;
|
||||||
|
transition: transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__fold.is-open {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__icon {
|
||||||
|
flex-basis: 26px;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: #f1f7f9;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__department-list {
|
||||||
|
padding: 3px 0 3px 12px;
|
||||||
|
margin: 2px 0 5px 15px;
|
||||||
|
border-left: 1px dashed #d9e6ec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__department {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__child-department {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__edit {
|
||||||
|
padding: 3px 6px;
|
||||||
|
margin-right: 6px;
|
||||||
|
color: var(--brand);
|
||||||
|
font-size: 10.5px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d3e7ee;
|
||||||
|
border-radius: 5px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__node:hover .org-tree__edit,
|
||||||
|
.org-tree__node:focus-within .org-tree__edit {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-tree__empty {
|
||||||
|
padding: 10px;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,6 +11,8 @@ const props = defineProps<{
|
|||||||
campuses: CampusRecord[]
|
campuses: CampusRecord[]
|
||||||
departments: DepartmentRecord[]
|
departments: DepartmentRecord[]
|
||||||
roles: RoleRecord[]
|
roles: RoleRecord[]
|
||||||
|
defaultCampusId?: string
|
||||||
|
defaultDepartmentId?: string | null
|
||||||
canAssignRoles: boolean
|
canAssignRoles: boolean
|
||||||
saving: boolean
|
saving: boolean
|
||||||
}>()
|
}>()
|
||||||
@@ -34,12 +36,15 @@ function createEmptyForm(): UserEditorForm {
|
|||||||
password: '',
|
password: '',
|
||||||
displayName: '',
|
displayName: '',
|
||||||
employeeNo: '',
|
employeeNo: '',
|
||||||
|
position: '',
|
||||||
|
professionalTitle: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
email: '',
|
email: '',
|
||||||
campusId: props.campuses[0]?.id ?? '',
|
campusId: props.defaultCampusId ?? props.campuses[0]?.id ?? '',
|
||||||
departmentId: '',
|
departmentId: props.defaultDepartmentId ?? '',
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
roleIds: [],
|
roleIds: [],
|
||||||
|
passwordChangeRequired: true,
|
||||||
clearPhone: false,
|
clearPhone: false,
|
||||||
clearEmail: false,
|
clearEmail: false,
|
||||||
}
|
}
|
||||||
@@ -98,6 +103,11 @@ function submit() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!form.employeeNo.trim()) {
|
||||||
|
error.message = '请输入工号'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!form.campusId) {
|
if (!form.campusId) {
|
||||||
error.message = '请选择院区'
|
error.message = '请选择院区'
|
||||||
return
|
return
|
||||||
@@ -152,11 +162,11 @@ watch(
|
|||||||
>
|
>
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
<strong id="user-editor-title">{{ isEditing ? '编辑用户' : '新增用户' }}</strong>
|
<strong id="user-editor-title">{{ isEditing ? '编辑员工' : '新增员工' }}</strong>
|
||||||
<p>
|
<p>
|
||||||
{{
|
{{
|
||||||
isEditing
|
isEditing
|
||||||
? '更新用户基础资料;权限和数据范围由绑定角色决定'
|
? '维护员工资料与组织归属;权限和数据范围由绑定角色决定'
|
||||||
: '创建可登录医签通的医护用户'
|
: '创建可登录医签通的医护用户'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
@@ -168,6 +178,11 @@ watch(
|
|||||||
|
|
||||||
<form class="dialog-body" @submit.prevent="submit">
|
<form class="dialog-body" @submit.prevent="submit">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
|
<div class="form-section">① 身份与归属</div>
|
||||||
|
<label>
|
||||||
|
<span>系统用户 ID</span>
|
||||||
|
<input :value="isEditing ? (form.employeeId ?? '—') : '保存后自动生成'" disabled />
|
||||||
|
</label>
|
||||||
<label v-if="!isEditing">
|
<label v-if="!isEditing">
|
||||||
<span>登录账号 <em>*</em></span>
|
<span>登录账号 <em>*</em></span>
|
||||||
<input v-model="form.username" autocomplete="username" placeholder="如:zhangsan" />
|
<input v-model="form.username" autocomplete="username" placeholder="如:zhangsan" />
|
||||||
@@ -190,8 +205,17 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>工号</span>
|
<span>工号 <em>*</em></span>
|
||||||
<input v-model="form.employeeNo" placeholder="请输入工号" />
|
<input v-model="form.employeeNo" :disabled="isEditing" placeholder="请输入工号" />
|
||||||
|
<small v-if="isEditing">工号为人员主数据,创建后不可修改</small>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>岗位</span>
|
||||||
|
<input v-model="form.position" placeholder="如:放射科医师" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>职称</span>
|
||||||
|
<input v-model="form.professionalTitle" placeholder="如:主治医师" />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
<span>手机号</span>
|
<span>手机号</span>
|
||||||
@@ -213,6 +237,7 @@ watch(
|
|||||||
@input="markEmailEdited"
|
@input="markEmailEdited"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="form-section">② 角色(权限和数据范围由角色统一配置)</div>
|
||||||
<label>
|
<label>
|
||||||
<span>院区 <em>*</em></span>
|
<span>院区 <em>*</em></span>
|
||||||
<select v-model="form.campusId" @change="handleCampusChange">
|
<select v-model="form.campusId" @change="handleCampusChange">
|
||||||
@@ -235,6 +260,23 @@ watch(
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label v-if="canAssignRoles" class="form-grid__full">
|
||||||
|
<span>角色(可多选)</span>
|
||||||
|
<select v-model="form.roleIds" multiple size="4">
|
||||||
|
<option v-for="role in roles" :key="role.id" :value="role.id">
|
||||||
|
{{ role.name }}({{ role.code }}){{
|
||||||
|
role.status === 'DISABLED' ? '(已停用)' : ''
|
||||||
|
}}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<small>多个角色的菜单、操作与数据权限按系统规则合并。</small>
|
||||||
|
</label>
|
||||||
|
<label v-else class="form-grid__full">
|
||||||
|
<span>已绑定角色</span>
|
||||||
|
<input :value="form.roleLabels?.join('、') || '未分配角色'" disabled />
|
||||||
|
<small>员工角色绑定权限由组织管理单独控制。</small>
|
||||||
|
</label>
|
||||||
|
<div class="form-section">③ 账号与安全</div>
|
||||||
<label>
|
<label>
|
||||||
<span>状态</span>
|
<span>状态</span>
|
||||||
<select v-model="form.status">
|
<select v-model="form.status">
|
||||||
@@ -243,21 +285,13 @@ watch(
|
|||||||
<option value="locked">锁定</option>
|
<option value="locked">锁定</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label v-if="canAssignRoles" class="form-grid__full">
|
<label v-if="!isEditing" class="checkbox-field">
|
||||||
<span>角色</span>
|
<input v-model="form.passwordChangeRequired" type="checkbox" />
|
||||||
<select v-model="form.roleIds" multiple size="4">
|
<span>首次登录强制修改密码</span>
|
||||||
<option v-for="role in roles" :key="role.id" :value="role.id">
|
|
||||||
{{ role.name }}({{ role.code }}){{
|
|
||||||
role.status === 'DISABLED' ? '(已停用)' : ''
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<small>可按住 Ctrl / Command 多选;保存时会完整替换用户角色。</small>
|
|
||||||
</label>
|
</label>
|
||||||
<label v-else class="form-grid__full">
|
<label v-else>
|
||||||
<span>已绑定角色</span>
|
<span>首次登录强制修改密码</span>
|
||||||
<input :value="form.roleLabels?.join('、') || '未分配角色'" disabled />
|
<input :value="form.passwordChangeRequired ? '是' : '否'" disabled />
|
||||||
<small>员工角色绑定权限由组织管理单独控制。</small>
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||||||
@@ -352,6 +386,16 @@ watch(
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--brand-d);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #f2f8fa;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.form-grid label > span {
|
.form-grid label > span {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -397,6 +441,18 @@ watch(
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.checkbox-field {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px !important;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-field input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.form-error {
|
.form-error {
|
||||||
margin: 12px 0 0;
|
margin: 12px 0 0;
|
||||||
color: var(--err);
|
color: var(--err);
|
||||||
|
|||||||
@@ -4,18 +4,21 @@ import { reactive, watch } from 'vue'
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
userName: string
|
userName: string
|
||||||
|
employeeId?: number | null
|
||||||
|
employeeNo?: string
|
||||||
saving: boolean
|
saving: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
close: []
|
close: []
|
||||||
reset: [payload: { newPassword: string; forceChangeOnNextLogin: boolean }]
|
reset: [payload: { newPassword: string; forceChangeOnNextLogin: boolean; reason: string }]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
forceChangeOnNextLogin: true,
|
forceChangeOnNextLogin: true,
|
||||||
|
reason: '',
|
||||||
})
|
})
|
||||||
const error = reactive({ message: '' })
|
const error = reactive({ message: '' })
|
||||||
|
|
||||||
@@ -23,6 +26,7 @@ function resetForm() {
|
|||||||
form.newPassword = ''
|
form.newPassword = ''
|
||||||
form.confirmPassword = ''
|
form.confirmPassword = ''
|
||||||
form.forceChangeOnNextLogin = true
|
form.forceChangeOnNextLogin = true
|
||||||
|
form.reason = ''
|
||||||
error.message = ''
|
error.message = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,9 +43,15 @@ function submit() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!form.reason.trim()) {
|
||||||
|
error.message = '请填写重置原因'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
emit('reset', {
|
emit('reset', {
|
||||||
newPassword: form.newPassword,
|
newPassword: form.newPassword,
|
||||||
forceChangeOnNextLogin: form.forceChangeOnNextLogin,
|
forceChangeOnNextLogin: form.forceChangeOnNextLogin,
|
||||||
|
reason: form.reason.trim(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,8 +76,8 @@ watch(
|
|||||||
>
|
>
|
||||||
<header class="dialog-header">
|
<header class="dialog-header">
|
||||||
<div>
|
<div>
|
||||||
<strong id="password-reset-title">重置用户密码</strong>
|
<strong id="password-reset-title">重置密码</strong>
|
||||||
<p>为“{{ userName }}”设置新的登录密码</p>
|
<p>{{ userName }} · ID {{ employeeId ?? '—' }} · 工号 {{ employeeNo || '—' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
|
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
|
||||||
×
|
×
|
||||||
@@ -75,6 +85,12 @@ watch(
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form class="dialog-body" @submit.prevent="submit">
|
<form class="dialog-body" @submit.prevent="submit">
|
||||||
|
<div class="reset-static">
|
||||||
|
<span>重置对象</span>
|
||||||
|
<strong
|
||||||
|
>{{ userName }}(ID {{ employeeId ?? '—' }} · 工号 {{ employeeNo || '—' }})</strong
|
||||||
|
>
|
||||||
|
</div>
|
||||||
<label>
|
<label>
|
||||||
<span>新密码 <em>*</em></span>
|
<span>新密码 <em>*</em></span>
|
||||||
<input
|
<input
|
||||||
@@ -99,6 +115,15 @@ watch(
|
|||||||
<input v-model="form.forceChangeOnNextLogin" type="checkbox" />
|
<input v-model="form.forceChangeOnNextLogin" type="checkbox" />
|
||||||
<span>要求用户下次登录时修改密码</span>
|
<span>要求用户下次登录时修改密码</span>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="reset-session-note">确认后将撤销该员工的全部现有会话。</div>
|
||||||
|
<label>
|
||||||
|
<span>重置原因 <em>*</em></span>
|
||||||
|
<textarea
|
||||||
|
v-model="form.reason"
|
||||||
|
rows="3"
|
||||||
|
placeholder="如:用户遗忘密码 / 安全事件处置"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
<p v-if="error.message" class="form-error">{{ error.message }}</p>
|
||||||
<p class="form-tip">密码仅提交给服务端校验和存储,不会展示在列表、响应或日志中。</p>
|
<p class="form-tip">密码仅提交给服务端校验和存储,不会展示在列表、响应或日志中。</p>
|
||||||
</form>
|
</form>
|
||||||
@@ -174,6 +199,32 @@ watch(
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.reset-static {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
background: #f5f9fa;
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-static span {
|
||||||
|
color: var(--mut);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-static strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reset-session-note {
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #765516;
|
||||||
|
font-size: 11.5px;
|
||||||
|
background: #fff6df;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-body label {
|
.dialog-body label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -201,6 +252,23 @@ watch(
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-body textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
resize: vertical;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-body textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--brand);
|
||||||
|
}
|
||||||
|
|
||||||
.dialog-body input[type='password']:focus {
|
.dialog-body input[type='password']:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--brand);
|
border-color: var(--brand);
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { UserStatus, UserTableRow } from '../types'
|
import type { UserStatus, UserTableRow } from '../types'
|
||||||
|
|
||||||
const props = defineProps<{
|
defineProps<{
|
||||||
rows: UserTableRow[]
|
rows: UserTableRow[]
|
||||||
roleColors: Record<string, string>
|
roleColors: Record<string, string>
|
||||||
disablingId?: string | null
|
|
||||||
canEdit?: boolean
|
canEdit?: boolean
|
||||||
canResetPassword?: boolean
|
canResetPassword?: boolean
|
||||||
canDeactivate?: boolean
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
edit: [row: UserTableRow]
|
edit: [row: UserTableRow]
|
||||||
resetPassword: [row: UserTableRow]
|
resetPassword: [row: UserTableRow]
|
||||||
deactivate: [row: UserTableRow]
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const statusLabels: Record<UserStatus, string> = {
|
const statusLabels: Record<UserStatus, string> = {
|
||||||
@@ -28,17 +25,24 @@ const statusLabels: Record<UserStatus, string> = {
|
|||||||
<table class="user-table">
|
<table class="user-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>用户</th>
|
<th class="id-column">ID</th>
|
||||||
<th>工号</th>
|
<th class="employee-column">工号</th>
|
||||||
|
<th>姓名</th>
|
||||||
<th>院区</th>
|
<th>院区</th>
|
||||||
<th>科室</th>
|
<th>科室</th>
|
||||||
|
<th>岗位</th>
|
||||||
|
<th>职称</th>
|
||||||
|
<th>手机</th>
|
||||||
<th>角色</th>
|
<th>角色</th>
|
||||||
|
<th>数据范围</th>
|
||||||
<th>状态</th>
|
<th>状态</th>
|
||||||
<th>操作</th>
|
<th class="actions-column">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="row in rows" :key="row.id">
|
<tr v-for="row in rows" :key="row.id">
|
||||||
|
<td class="mono">{{ row.employeeId ?? '—' }}</td>
|
||||||
|
<td class="mono">{{ row.employeeNo }}</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="user-cell">
|
<div class="user-cell">
|
||||||
<span
|
<span
|
||||||
@@ -53,55 +57,46 @@ const statusLabels: Record<UserStatus, string> = {
|
|||||||
<strong>{{ row.name }}</strong>
|
<strong>{{ row.name }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ row.employeeNo }}</td>
|
|
||||||
<td>{{ row.campus }}</td>
|
<td>{{ row.campus }}</td>
|
||||||
<td>{{ row.department }}</td>
|
<td>{{ row.department }}</td>
|
||||||
|
<td>{{ row.position || '—' }}</td>
|
||||||
|
<td>{{ row.professionalTitle || '—' }}</td>
|
||||||
|
<td class="mono">{{ row.phone || '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span
|
<span
|
||||||
|
v-for="name in row.roleNames?.length ? row.roleNames : [row.role]"
|
||||||
|
:key="name"
|
||||||
class="role-tag"
|
class="role-tag"
|
||||||
:style="{
|
:style="{
|
||||||
color: roleColors[row.roleNames?.[0] ?? row.role] ?? '#8a9aa3',
|
color: roleColors[name] ?? '#52717e',
|
||||||
backgroundColor: `${roleColors[row.roleNames?.[0] ?? row.role] ?? '#8a9aa3'}22`,
|
backgroundColor: `${roleColors[name] ?? '#8a9aa3'}22`,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ row.role }}
|
{{ name }}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td>{{ row.dataScope || '—' }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="status-tag" :class="'status-' + row.status">
|
<span class="status-tag" :class="'status-' + row.status">{{
|
||||||
{{ statusLabels[row.status] }}
|
statusLabels[row.status]
|
||||||
</span>
|
}}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="actions-cell">
|
<td class="actions-cell">
|
||||||
<button
|
<button v-if="canEdit" type="button" class="table-button" @click="emit('edit', row)">
|
||||||
v-if="props.canEdit"
|
|
||||||
type="button"
|
|
||||||
class="table-button"
|
|
||||||
@click="emit('edit', row)"
|
|
||||||
>
|
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="props.canResetPassword"
|
v-if="canResetPassword"
|
||||||
type="button"
|
type="button"
|
||||||
class="table-button"
|
class="table-button"
|
||||||
@click="emit('resetPassword', row)"
|
@click="emit('resetPassword', row)"
|
||||||
>
|
>
|
||||||
重置密码
|
重置密码
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
v-if="props.canDeactivate && row.status === 'enabled'"
|
|
||||||
type="button"
|
|
||||||
class="table-button table-button--danger"
|
|
||||||
:disabled="disablingId === row.id"
|
|
||||||
@click="emit('deactivate', row)"
|
|
||||||
>
|
|
||||||
{{ disablingId === row.id ? '停用中…' : '停用' }}
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="!rows.length">
|
<tr v-if="!rows.length">
|
||||||
<td colspan="7" class="empty-cell">没有找到匹配的用户</td>
|
<td colspan="12" class="empty-cell">该节点下暂无员工</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -116,15 +111,15 @@ const statusLabels: Record<UserStatus, string> = {
|
|||||||
|
|
||||||
.user-table {
|
.user-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 1040px;
|
min-width: 1420px;
|
||||||
font-size: 13px;
|
font-size: 12.5px;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-table th {
|
.user-table th {
|
||||||
padding: 9px 12px;
|
padding: 9px 10px;
|
||||||
color: var(--mut);
|
color: var(--mut);
|
||||||
font-size: 12.5px;
|
font-size: 11.5px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -133,7 +128,7 @@ const statusLabels: Record<UserStatus, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.user-table td {
|
.user-table td {
|
||||||
padding: 9px 12px;
|
padding: 9px 10px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
border-bottom: 1px solid #eef4f6;
|
border-bottom: 1px solid #eef4f6;
|
||||||
}
|
}
|
||||||
@@ -142,29 +137,51 @@ const statusLabels: Record<UserStatus, string> = {
|
|||||||
background: #f7fbfc;
|
background: #f7fbfc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.id-column {
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-column {
|
||||||
|
min-width: 76px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-column {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.user-cell {
|
.user-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 9px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
display: grid;
|
display: grid;
|
||||||
flex-shrink: 0;
|
flex: 0 0 26px;
|
||||||
width: 30px;
|
width: 26px;
|
||||||
height: 30px;
|
height: 26px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
border: 1px solid #d3e7ee;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
color: #4a6571;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 11.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.role-tag,
|
.role-tag,
|
||||||
.status-tag {
|
.status-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 2px 10px;
|
padding: 2px 9px;
|
||||||
font-size: 11.5px;
|
margin: 1px 3px 1px 0;
|
||||||
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
@@ -190,9 +207,9 @@ const statusLabels: Record<UserStatus, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.table-button {
|
.table-button {
|
||||||
padding: 4px 10px;
|
padding: 4px 9px;
|
||||||
color: var(--brand);
|
color: var(--brand);
|
||||||
font-size: 12px;
|
font-size: 11.5px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid var(--brand);
|
border: 1px solid var(--brand);
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -206,22 +223,8 @@ const statusLabels: Record<UserStatus, string> = {
|
|||||||
background: var(--brand-l);
|
background: var(--brand-l);
|
||||||
}
|
}
|
||||||
|
|
||||||
.table-button--danger {
|
|
||||||
color: var(--err);
|
|
||||||
border-color: var(--err);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-button--danger:hover {
|
|
||||||
background: var(--err-l);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-button:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-cell {
|
.empty-cell {
|
||||||
padding: 40px 12px !important;
|
padding: 34px 12px !important;
|
||||||
color: var(--mut);
|
color: var(--mut);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,65 +10,186 @@ export const roleColors: Record<string, string> = {
|
|||||||
export const users: UserTableRow[] = [
|
export const users: UserTableRow[] = [
|
||||||
{
|
{
|
||||||
id: 'user-001',
|
id: 'user-001',
|
||||||
|
employeeId: 1002,
|
||||||
name: '张文静',
|
name: '张文静',
|
||||||
employeeNo: '10234',
|
employeeNo: '10234',
|
||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
department: '医务处',
|
department: '医务处',
|
||||||
|
position: '医务管理',
|
||||||
|
professionalTitle: '主任医师',
|
||||||
|
phone: '138****5678',
|
||||||
role: '医务管理',
|
role: '医务管理',
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-002',
|
id: 'user-002',
|
||||||
name: '刘明辉',
|
employeeId: 1003,
|
||||||
employeeNo: '10301',
|
name: '吴敏',
|
||||||
|
employeeNo: '10235',
|
||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
department: '放射科',
|
department: '医务处',
|
||||||
role: '科室签署员',
|
position: '医务干事',
|
||||||
|
professionalTitle: '主治医师',
|
||||||
|
phone: '139****0092',
|
||||||
|
role: '医务管理',
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-003',
|
id: 'user-003',
|
||||||
name: '王芳',
|
employeeId: 1004,
|
||||||
employeeNo: '10455',
|
name: '刘明辉',
|
||||||
|
employeeNo: '10301',
|
||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
department: '心内科',
|
department: '放射科',
|
||||||
|
position: '放射科医师',
|
||||||
|
professionalTitle: '副主任医师',
|
||||||
|
phone: '137****8845',
|
||||||
role: '科室签署员',
|
role: '科室签署员',
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-004',
|
id: 'user-004',
|
||||||
name: '李国栋',
|
employeeId: 1005,
|
||||||
employeeNo: '10512',
|
name: '高倩',
|
||||||
campus: '东院区',
|
employeeNo: '10302',
|
||||||
department: '骨科',
|
campus: '本部院区',
|
||||||
|
department: '放射科',
|
||||||
|
position: '登记员',
|
||||||
|
professionalTitle: '技师',
|
||||||
|
phone: '150****3311',
|
||||||
role: '科室签署员',
|
role: '科室签署员',
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-005',
|
id: 'user-005',
|
||||||
name: '陈晓云',
|
employeeId: 1006,
|
||||||
employeeNo: '10188',
|
name: '王芳',
|
||||||
|
employeeNo: '10455',
|
||||||
campus: '本部院区',
|
campus: '本部院区',
|
||||||
department: '信息科',
|
department: '心内科',
|
||||||
role: '系统管理员',
|
position: '心内科医师',
|
||||||
|
professionalTitle: '主治医师',
|
||||||
|
phone: '136****7789',
|
||||||
|
role: '科室签署员',
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-006',
|
id: 'user-006',
|
||||||
|
employeeId: 1008,
|
||||||
|
name: '钱进',
|
||||||
|
employeeNo: '10520',
|
||||||
|
campus: '本部院区',
|
||||||
|
department: '骨科',
|
||||||
|
position: '骨科医师',
|
||||||
|
professionalTitle: '主任医师',
|
||||||
|
phone: '188****2210',
|
||||||
|
role: '科室签署员',
|
||||||
|
status: 'enabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-007',
|
||||||
|
employeeId: 1001,
|
||||||
|
name: '陈晓云',
|
||||||
|
account: 'admin',
|
||||||
|
employeeNo: '10188',
|
||||||
|
campus: '本部院区',
|
||||||
|
department: '信息科',
|
||||||
|
position: '系统管理员',
|
||||||
|
professionalTitle: '工程师',
|
||||||
|
phone: '150****6644',
|
||||||
|
role: '系统管理员',
|
||||||
|
status: 'enabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-008',
|
||||||
|
employeeId: 1011,
|
||||||
|
name: '孙立',
|
||||||
|
account: 'sunli',
|
||||||
|
employeeNo: '10733',
|
||||||
|
campus: '本部院区',
|
||||||
|
department: '纪检监察室',
|
||||||
|
position: '审计专员',
|
||||||
|
professionalTitle: '—',
|
||||||
|
phone: '139****6621',
|
||||||
|
role: '查询审计员',
|
||||||
|
status: 'enabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-009',
|
||||||
|
employeeId: 1007,
|
||||||
|
name: '李国栋',
|
||||||
|
employeeNo: '10512',
|
||||||
|
campus: '东院区',
|
||||||
|
department: '骨科',
|
||||||
|
position: '骨科医师',
|
||||||
|
professionalTitle: '副主任医师',
|
||||||
|
phone: '135****9051',
|
||||||
|
role: '科室签署员',
|
||||||
|
status: 'enabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-010',
|
||||||
|
employeeId: 1009,
|
||||||
name: '赵雪梅',
|
name: '赵雪梅',
|
||||||
employeeNo: '10602',
|
employeeNo: '10602',
|
||||||
campus: '东院区',
|
campus: '东院区',
|
||||||
department: '健康管理中心',
|
department: '健康管理中心',
|
||||||
|
position: '体检医师',
|
||||||
|
professionalTitle: '主治医师',
|
||||||
|
phone: '187****3345',
|
||||||
role: '科室签署员',
|
role: '科室签署员',
|
||||||
status: 'disabled',
|
status: 'disabled',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'user-007',
|
id: 'user-011',
|
||||||
name: '孙立',
|
employeeId: 1010,
|
||||||
employeeNo: '10733',
|
name: '孙浩然',
|
||||||
campus: '本部院区',
|
employeeNo: '10701',
|
||||||
department: '纪检监察室',
|
campus: '西院区',
|
||||||
role: '查询审计员',
|
department: '消化内科',
|
||||||
|
position: '消化科医师',
|
||||||
|
professionalTitle: '住院医师',
|
||||||
|
phone: '150****4468',
|
||||||
|
role: '科室签署员',
|
||||||
|
status: 'enabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-012',
|
||||||
|
employeeId: 1012,
|
||||||
|
name: '顾建平',
|
||||||
|
employeeNo: '20101',
|
||||||
|
campus: '崇明院区',
|
||||||
|
department: '内科',
|
||||||
|
position: '内科医师',
|
||||||
|
professionalTitle: '主任医师',
|
||||||
|
phone: '139****1120',
|
||||||
|
role: '科室签署员',
|
||||||
|
status: 'enabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-013',
|
||||||
|
employeeId: 1013,
|
||||||
|
name: '黄丽',
|
||||||
|
employeeNo: '20102',
|
||||||
|
campus: '崇明院区',
|
||||||
|
department: '内科',
|
||||||
|
position: '护理组长',
|
||||||
|
professionalTitle: '主管护师',
|
||||||
|
phone: '137****5561',
|
||||||
|
role: '科室签署员',
|
||||||
|
status: 'enabled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-014',
|
||||||
|
employeeId: 1014,
|
||||||
|
name: '沈涛',
|
||||||
|
employeeNo: '20105',
|
||||||
|
campus: '崇明院区',
|
||||||
|
department: '放射科',
|
||||||
|
position: '放射科医师',
|
||||||
|
professionalTitle: '主治医师',
|
||||||
|
phone: '150****7782',
|
||||||
|
role: '科室签署员',
|
||||||
status: 'enabled',
|
status: 'enabled',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ export type UserStatus = 'enabled' | 'disabled' | 'locked'
|
|||||||
|
|
||||||
export interface UserTableRow {
|
export interface UserTableRow {
|
||||||
id: string
|
id: string
|
||||||
|
employeeId?: number | null
|
||||||
name: string
|
name: string
|
||||||
account?: string
|
account?: string
|
||||||
employeeNo: string
|
employeeNo: string
|
||||||
|
position?: string | null
|
||||||
|
professionalTitle?: string | null
|
||||||
phone?: string
|
phone?: string
|
||||||
email?: string
|
email?: string
|
||||||
campus: string
|
campus: string
|
||||||
@@ -14,16 +17,21 @@ export interface UserTableRow {
|
|||||||
role: string
|
role: string
|
||||||
roleNames?: string[]
|
roleNames?: string[]
|
||||||
roleIds?: string[]
|
roleIds?: string[]
|
||||||
|
dataScope?: string
|
||||||
|
passwordChangeRequired?: boolean
|
||||||
status: UserStatus
|
status: UserStatus
|
||||||
version?: number
|
version?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserEditorForm {
|
export interface UserEditorForm {
|
||||||
id?: string
|
id?: string
|
||||||
|
employeeId?: number | null
|
||||||
username: string
|
username: string
|
||||||
password: string
|
password: string
|
||||||
displayName: string
|
displayName: string
|
||||||
employeeNo: string
|
employeeNo: string
|
||||||
|
position: string
|
||||||
|
professionalTitle: string
|
||||||
phone: string
|
phone: string
|
||||||
phoneMasked?: boolean
|
phoneMasked?: boolean
|
||||||
email: string
|
email: string
|
||||||
@@ -33,6 +41,7 @@ export interface UserEditorForm {
|
|||||||
status: UserStatus
|
status: UserStatus
|
||||||
roleIds: string[]
|
roleIds: string[]
|
||||||
roleLabels?: string[]
|
roleLabels?: string[]
|
||||||
|
passwordChangeRequired?: boolean
|
||||||
clearPhone?: boolean
|
clearPhone?: boolean
|
||||||
clearEmail?: boolean
|
clearEmail?: boolean
|
||||||
version?: number
|
version?: number
|
||||||
|
|||||||
Reference in New Issue
Block a user