feat(组织架构): 完成员工与组织节点管理

This commit is contained in:
xy
2026-09-24 16:22:21 +08:00
parent 9a6ac93e21
commit 9145b57e6f
14 changed files with 1908 additions and 501 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ TanStack Vue Query、PDF 预览、报表图表、自动化测试和签字板适
## 当前状态
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台和文档库管理页面。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
已完成 Vite 基础初始化、路由、Pinia、原型主题 CSS、登录页、首页、签署工作台和文档库管理页面。角色权限与系统设置中的菜单设置已接入 MEDISIGN RBAC 接口;导航、路由和页面操作按授权资源树控制,角色数据范围在角色配置中维护,员工页面只负责绑定角色。组织架构页按机构→院区→科室分组展示员工列表,新增院区默认归属“新华院区”,支持节点筛选、员工维护和组织节点维护。系统参数卡片仍保留 Mock,真实签字板/线上签名回调和打印服务仍需设备或后端能力。
文档库管理的真实模式已打通「模板 → 版本 → 正文」三段:
+26 -16
View File
@@ -20,26 +20,35 @@ function getRecords(data: BackendCollection<OrganizationResponseDto>) {
return Array.isArray(data) ? data : (data.records ?? data.items ?? data.content ?? [])
}
export function getCampuses(): Promise<CampusRecord[]> {
export async function getCampuses(): Promise<CampusRecord[]> {
if (useMockData) {
return Promise.resolve([])
return []
}
return request
.get<ApiResponse<BackendCollection<OrganizationResponseDto>>>('/v1/campuses', {
params: { page: 1, size: 200 },
})
.then(unwrapApiResponse)
.then((data) =>
getRecords(data).map((item) => ({
id: item.id,
code: item.code ?? item.id,
name: item.name ?? item.campusName ?? item.id,
address: item.address ?? undefined,
status: item.status ?? 'ENABLED',
sortNo: item.sortNo ?? 0,
})),
const firstPage = await getCampusPage(1, 200)
const pageCount = Math.ceil(firstPage.total / firstPage.pageSize)
if (pageCount <= 1) return firstPage.records
const remainingPages = await Promise.all(
Array.from({ length: pageCount - 1 }, (_, index) =>
getCampusPage(index + 2, firstPage.pageSize),
),
)
return [firstPage, ...remainingPages].flatMap((page) => page.records)
}
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[]> {
@@ -102,6 +111,7 @@ function mapCampus(data: OrganizationResponseDto): CampusRecord {
id: data.id,
code: data.code ?? data.id,
name: data.name ?? data.campusName ?? data.id,
institutionName: data.institutionName?.trim() || '新华院区',
address: data.address ?? undefined,
status: data.status ?? 'ENABLED',
sortNo: data.sortNo ?? 0,
+18
View File
@@ -234,6 +234,7 @@ export interface CampusRecord {
id: string
code: string
name: string
institutionName: string
address?: string
status: OrganizationStatus
sortNo: number
@@ -254,6 +255,7 @@ export type OrganizationStatus = 'ENABLED' | 'DISABLED' | string
export interface CreateCampusRequest {
code: string
name: string
institutionName?: string
address?: string
status?: OrganizationStatus
sortNo?: number
@@ -276,6 +278,7 @@ export interface OrganizationResponseDto {
id: string
code?: string | null
name?: string | null
institutionName?: string | null
address?: string | null
status?: OrganizationStatus | null
sortNo?: number | null
@@ -422,12 +425,14 @@ export type UserStatus = 'enabled' | 'disabled' | 'locked'
export interface UserQuery extends PageQuery {
keyword?: string
department?: string
departmentId?: string
status?: UserStatus | 'all'
campusId?: string
}
export interface UserRecord {
id: string
employeeId?: number | null
name: string
account: string
department: string
@@ -435,6 +440,8 @@ export interface UserRecord {
status: UserStatus
lastLoginAt: string
employeeNo?: string | null
position?: string | null
professionalTitle?: string | null
phone?: string | null
email?: string | null
campusId?: string | null
@@ -443,6 +450,7 @@ export interface UserRecord {
departmentName?: string
roleNames?: string[]
roleIds?: string[]
roleDataScopes?: RoleDataScopeType[]
version?: number
passwordChangeRequired?: boolean
}
@@ -451,9 +459,12 @@ export type UserListResponse = PageResult<UserRecord>
export interface UserResponseDto {
id: string
employeeId?: number | null
username: string
displayName: string
employeeNo?: string | null
position?: string | null
professionalTitle?: string | null
phone?: string | null
email?: string | null
campusId?: string | null
@@ -474,6 +485,7 @@ export interface UserRoleSummaryDto {
code: string
name: string
status?: RoleStatus | null
dataScope?: RoleDataScopeType | null
}
export interface CreateUserRequest {
@@ -481,11 +493,14 @@ export interface CreateUserRequest {
password: string
displayName: string
employeeNo?: string
position?: string
professionalTitle?: string
phone?: string
email?: string
campusId: string
departmentId?: string | null
status?: BackendUserStatus
passwordChangeRequired?: boolean
}
export type BackendUserStatus = 'ENABLED' | 'DISABLED' | 'LOCKED'
@@ -493,6 +508,8 @@ export type BackendUserStatus = 'ENABLED' | 'DISABLED' | 'LOCKED'
export interface UpdateUserRequest {
displayName: string
employeeNo?: string
position?: string
professionalTitle?: string
phone?: string
email?: string
campusId: string
@@ -622,6 +639,7 @@ export interface UpdateDepartmentPermissionPolicyRequest {
export interface PasswordResetRequest {
newPassword: string
forceChangeOnNextLogin?: boolean
reason: string
}
export type UserImportRowStatus = 'VALID' | 'INVALID'
+12 -1
View File
@@ -90,6 +90,7 @@ function mapUser(dto: UserResponseDto): UserRecord {
return {
id: dto.id,
employeeId: dto.employeeId,
name: dto.displayName,
account: dto.username,
department: dto.departmentName ?? (dto.departmentId ? '未知科室' : '未指定科室'),
@@ -97,6 +98,8 @@ function mapUser(dto: UserResponseDto): UserRecord {
status: normalizeStatus(dto.status),
lastLoginAt: formatDateTime(dto.lastLoginAt),
employeeNo: dto.employeeNo,
position: dto.position,
professionalTitle: dto.professionalTitle,
phone: dto.phone,
email: dto.email,
campusId: dto.campusId,
@@ -105,6 +108,7 @@ function mapUser(dto: UserResponseDto): UserRecord {
departmentName: dto.departmentName ?? undefined,
roleNames,
roleIds: roles.map((role) => role.id),
roleDataScopes: roles.map((role) => role.dataScope ?? 'SELF'),
version: dto.version ?? undefined,
passwordChangeRequired: dto.passwordChangeRequired,
}
@@ -139,6 +143,10 @@ function toBackendQuery(query: UserQuery) {
params.campusId = query.campusId
}
if (query.departmentId) {
params.departmentId = query.departmentId
}
return params
}
@@ -163,10 +171,13 @@ export function getUsers(query: UserQuery): Promise<UserListResponse> {
const matchesKeyword =
!keyword || [record.name, record.account].join(' ').toLowerCase().includes(keyword)
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 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 pageSize = Math.max(query.pageSize, 1)
+24 -3
View File
@@ -10,9 +10,30 @@ import type { WorkbenchCampus } from '@/api/workbench/types'
const useMockData = mockFlags.organization
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-west', code: 'WEST', name: '西院区', status: 'ENABLED', sortNo: 3 },
{
id: 'mock-campus-main',
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[]) {
@@ -227,11 +227,19 @@ const DEMO_DEPARTMENT_ORDER = ['全院通用', '放射科', '消化内科', '心
* 演示 id 带 mock- 前缀,一眼能看出不是真实 UUID,不会误当成可提交给后端的取值。
*/
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',
code: 'EAST',
name: '东院区',
institutionName: '新华院区',
status: 'ENABLED' as const,
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[]
departments: DepartmentRecord[]
roles: RoleRecord[]
defaultCampusId?: string
defaultDepartmentId?: string | null
canAssignRoles: boolean
saving: boolean
}>()
@@ -34,12 +36,15 @@ function createEmptyForm(): UserEditorForm {
password: '',
displayName: '',
employeeNo: '',
position: '',
professionalTitle: '',
phone: '',
email: '',
campusId: props.campuses[0]?.id ?? '',
departmentId: '',
campusId: props.defaultCampusId ?? props.campuses[0]?.id ?? '',
departmentId: props.defaultDepartmentId ?? '',
status: 'enabled',
roleIds: [],
passwordChangeRequired: true,
clearPhone: false,
clearEmail: false,
}
@@ -98,6 +103,11 @@ function submit() {
return
}
if (!form.employeeNo.trim()) {
error.message = '请输入工号'
return
}
if (!form.campusId) {
error.message = '请选择院区'
return
@@ -152,11 +162,11 @@ watch(
>
<header class="dialog-header">
<div>
<strong id="user-editor-title">{{ isEditing ? '编辑用户' : '新增用户' }}</strong>
<strong id="user-editor-title">{{ isEditing ? '编辑员工' : '新增员工' }}</strong>
<p>
{{
isEditing
? '更新用户基础资料;权限和数据范围由绑定角色决定'
? '维护员工资料与组织归属;权限和数据范围由绑定角色决定'
: '创建可登录医签通的医护用户'
}}
</p>
@@ -168,6 +178,11 @@ watch(
<form class="dialog-body" @submit.prevent="submit">
<div class="form-grid">
<div class="form-section">① 身份与归属</div>
<label>
<span>系统用户 ID</span>
<input :value="isEditing ? (form.employeeId ?? '—') : '保存后自动生成'" disabled />
</label>
<label v-if="!isEditing">
<span>登录账号 <em>*</em></span>
<input v-model="form.username" autocomplete="username" placeholder="如:zhangsan" />
@@ -190,8 +205,17 @@ watch(
/>
</label>
<label>
<span>工号</span>
<input v-model="form.employeeNo" placeholder="请输入工号" />
<span>工号 <em>*</em></span>
<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>
<span>手机号</span>
@@ -213,6 +237,7 @@ watch(
@input="markEmailEdited"
/>
</label>
<div class="form-section">② 角色(权限和数据范围由角色统一配置)</div>
<label>
<span>院区 <em>*</em></span>
<select v-model="form.campusId" @change="handleCampusChange">
@@ -235,6 +260,23 @@ watch(
</option>
</select>
</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>
<span>状态</span>
<select v-model="form.status">
@@ -243,21 +285,13 @@ watch(
<option value="locked">锁定</option>
</select>
</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>可按住 Ctrl / Command 多选;保存时会完整替换用户角色。</small>
<label v-if="!isEditing" class="checkbox-field">
<input v-model="form.passwordChangeRequired" type="checkbox" />
<span>首次登录强制修改密码</span>
</label>
<label v-else class="form-grid__full">
<span>已绑定角色</span>
<input :value="form.roleLabels?.join('、') || '未分配角色'" disabled />
<small>员工角色绑定权限由组织管理单独控制。</small>
<label v-else>
<span>首次登录强制修改密码</span>
<input :value="form.passwordChangeRequired ? '是' : '否'" disabled />
</label>
</div>
<p v-if="error.message" class="form-error">{{ error.message }}</p>
@@ -352,6 +386,16 @@ watch(
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 {
color: var(--ink);
font-size: 12px;
@@ -397,6 +441,18 @@ watch(
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 {
margin: 12px 0 0;
color: var(--err);
@@ -4,18 +4,21 @@ import { reactive, watch } from 'vue'
const props = defineProps<{
visible: boolean
userName: string
employeeId?: number | null
employeeNo?: string
saving: boolean
}>()
const emit = defineEmits<{
close: []
reset: [payload: { newPassword: string; forceChangeOnNextLogin: boolean }]
reset: [payload: { newPassword: string; forceChangeOnNextLogin: boolean; reason: string }]
}>()
const form = reactive({
newPassword: '',
confirmPassword: '',
forceChangeOnNextLogin: true,
reason: '',
})
const error = reactive({ message: '' })
@@ -23,6 +26,7 @@ function resetForm() {
form.newPassword = ''
form.confirmPassword = ''
form.forceChangeOnNextLogin = true
form.reason = ''
error.message = ''
}
@@ -39,9 +43,15 @@ function submit() {
return
}
if (!form.reason.trim()) {
error.message = '请填写重置原因'
return
}
emit('reset', {
newPassword: form.newPassword,
forceChangeOnNextLogin: form.forceChangeOnNextLogin,
reason: form.reason.trim(),
})
}
@@ -66,8 +76,8 @@ watch(
>
<header class="dialog-header">
<div>
<strong id="password-reset-title">重置用户密码</strong>
<p>为“{{ userName }}”设置新的登录密码</p>
<strong id="password-reset-title">重置密码</strong>
<p>{{ userName }} · ID {{ employeeId ?? '—' }} · 工号 {{ employeeNo || '—' }}</p>
</div>
<button type="button" class="close-button" aria-label="关闭" @click="emit('close')">
×
@@ -75,6 +85,12 @@ watch(
</header>
<form class="dialog-body" @submit.prevent="submit">
<div class="reset-static">
<span>重置对象</span>
<strong
>{{ userName }}(ID {{ employeeId ?? '—' }} · 工号 {{ employeeNo || '—' }})</strong
>
</div>
<label>
<span>新密码 <em>*</em></span>
<input
@@ -99,6 +115,15 @@ watch(
<input v-model="form.forceChangeOnNextLogin" type="checkbox" />
<span>要求用户下次登录时修改密码</span>
</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 class="form-tip">密码仅提交给服务端校验和存储,不会展示在列表、响应或日志中。</p>
</form>
@@ -174,6 +199,32 @@ watch(
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 {
display: grid;
gap: 6px;
@@ -201,6 +252,23 @@ watch(
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 {
outline: none;
border-color: var(--brand);
@@ -1,19 +1,16 @@
<script setup lang="ts">
import type { UserStatus, UserTableRow } from '../types'
const props = defineProps<{
defineProps<{
rows: UserTableRow[]
roleColors: Record<string, string>
disablingId?: string | null
canEdit?: boolean
canResetPassword?: boolean
canDeactivate?: boolean
}>()
const emit = defineEmits<{
edit: [row: UserTableRow]
resetPassword: [row: UserTableRow]
deactivate: [row: UserTableRow]
}>()
const statusLabels: Record<UserStatus, string> = {
@@ -28,17 +25,24 @@ const statusLabels: Record<UserStatus, string> = {
<table class="user-table">
<thead>
<tr>
<th>用户</th>
<th>工号</th>
<th class="id-column">ID</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 class="actions-column">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td class="mono">{{ row.employeeId ?? '—' }}</td>
<td class="mono">{{ row.employeeNo }}</td>
<td>
<div class="user-cell">
<span
@@ -53,55 +57,46 @@ const statusLabels: Record<UserStatus, string> = {
<strong>{{ row.name }}</strong>
</div>
</td>
<td>{{ row.employeeNo }}</td>
<td>{{ row.campus }}</td>
<td>{{ row.department }}</td>
<td>{{ row.position || '—' }}</td>
<td>{{ row.professionalTitle || '—' }}</td>
<td class="mono">{{ row.phone || '—' }}</td>
<td>
<span
v-for="name in row.roleNames?.length ? row.roleNames : [row.role]"
:key="name"
class="role-tag"
:style="{
color: roleColors[row.roleNames?.[0] ?? row.role] ?? '#8a9aa3',
backgroundColor: `${roleColors[row.roleNames?.[0] ?? row.role] ?? '#8a9aa3'}22`,
color: roleColors[name] ?? '#52717e',
backgroundColor: `${roleColors[name] ?? '#8a9aa3'}22`,
}"
>
{{ row.role }}
{{ name }}
</span>
</td>
<td>{{ row.dataScope || '—' }}</td>
<td>
<span class="status-tag" :class="'status-' + row.status">
{{ statusLabels[row.status] }}
</span>
<span class="status-tag" :class="'status-' + row.status">{{
statusLabels[row.status]
}}</span>
</td>
<td class="actions-cell">
<button
v-if="props.canEdit"
type="button"
class="table-button"
@click="emit('edit', row)"
>
<button v-if="canEdit" type="button" class="table-button" @click="emit('edit', row)">
编辑
</button>
<button
v-if="props.canResetPassword"
v-if="canResetPassword"
type="button"
class="table-button"
@click="emit('resetPassword', row)"
>
重置密码
</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>
</tr>
<tr v-if="!rows.length">
<td colspan="7" class="empty-cell">没有找到匹配的用户</td>
<td colspan="12" class="empty-cell">该节点下暂无员工</td>
</tr>
</tbody>
</table>
@@ -116,15 +111,15 @@ const statusLabels: Record<UserStatus, string> = {
.user-table {
width: 100%;
min-width: 1040px;
font-size: 13px;
min-width: 1420px;
font-size: 12.5px;
border-collapse: collapse;
}
.user-table th {
padding: 9px 12px;
padding: 9px 10px;
color: var(--mut);
font-size: 12.5px;
font-size: 11.5px;
font-weight: 700;
text-align: left;
white-space: nowrap;
@@ -133,7 +128,7 @@ const statusLabels: Record<UserStatus, string> = {
}
.user-table td {
padding: 9px 12px;
padding: 9px 10px;
vertical-align: middle;
border-bottom: 1px solid #eef4f6;
}
@@ -142,29 +137,51 @@ const statusLabels: Record<UserStatus, string> = {
background: #f7fbfc;
}
.id-column {
min-width: 64px;
}
.employee-column {
min-width: 76px;
}
.actions-column {
min-width: 180px;
}
.user-cell {
display: flex;
gap: 9px;
gap: 8px;
align-items: center;
white-space: nowrap;
}
.user-avatar {
display: grid;
flex-shrink: 0;
width: 30px;
height: 30px;
flex: 0 0 26px;
width: 26px;
height: 26px;
color: #fff;
font-size: 12px;
font-weight: 700;
place-items: center;
border: 1px solid #d3e7ee;
border-radius: 50%;
}
.mono {
color: #4a6571;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11.5px;
white-space: nowrap;
}
.role-tag,
.status-tag {
display: inline-block;
padding: 2px 10px;
font-size: 11.5px;
padding: 2px 9px;
margin: 1px 3px 1px 0;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
border-radius: 20px;
@@ -190,9 +207,9 @@ const statusLabels: Record<UserStatus, string> = {
}
.table-button {
padding: 4px 10px;
padding: 4px 9px;
color: var(--brand);
font-size: 12px;
font-size: 11.5px;
background: #fff;
border: 1px solid var(--brand);
border-radius: 5px;
@@ -206,22 +223,8 @@ const statusLabels: Record<UserStatus, string> = {
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 {
padding: 40px 12px !important;
padding: 34px 12px !important;
color: var(--mut);
text-align: center;
}
File diff suppressed because it is too large Load Diff
+142 -21
View File
@@ -10,65 +10,186 @@ export const roleColors: Record<string, string> = {
export const users: UserTableRow[] = [
{
id: 'user-001',
employeeId: 1002,
name: '张文静',
employeeNo: '10234',
campus: '本部院区',
department: '医务处',
position: '医务管理',
professionalTitle: '主任医师',
phone: '138****5678',
role: '医务管理',
status: 'enabled',
},
{
id: 'user-002',
name: '刘明辉',
employeeNo: '10301',
employeeId: 1003,
name: '吴敏',
employeeNo: '10235',
campus: '本部院区',
department: '放射科',
role: '科室签署员',
department: '医务处',
position: '医务干事',
professionalTitle: '主治医师',
phone: '139****0092',
role: '医务管理',
status: 'enabled',
},
{
id: 'user-003',
name: '王芳',
employeeNo: '10455',
employeeId: 1004,
name: '刘明辉',
employeeNo: '10301',
campus: '本部院区',
department: '心内科',
department: '放射科',
position: '放射科医师',
professionalTitle: '副主任医师',
phone: '137****8845',
role: '科室签署员',
status: 'enabled',
},
{
id: 'user-004',
name: '李国栋',
employeeNo: '10512',
campus: '东院区',
department: '骨科',
employeeId: 1005,
name: '高倩',
employeeNo: '10302',
campus: '本部院区',
department: '放射科',
position: '登记员',
professionalTitle: '技师',
phone: '150****3311',
role: '科室签署员',
status: 'enabled',
},
{
id: 'user-005',
name: '陈晓云',
employeeNo: '10188',
employeeId: 1006,
name: '王芳',
employeeNo: '10455',
campus: '本部院区',
department: '信息科',
role: '系统管理员',
department: '心内科',
position: '心内科医师',
professionalTitle: '主治医师',
phone: '136****7789',
role: '科室签署员',
status: 'enabled',
},
{
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: '赵雪梅',
employeeNo: '10602',
campus: '东院区',
department: '健康管理中心',
position: '体检医师',
professionalTitle: '主治医师',
phone: '187****3345',
role: '科室签署员',
status: 'disabled',
},
{
id: 'user-007',
name: '孙立',
employeeNo: '10733',
campus: '本部院区',
department: '纪检监察室',
role: '查询审计员',
id: 'user-011',
employeeId: 1010,
name: '孙浩然',
employeeNo: '10701',
campus: '西院区',
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',
},
]
@@ -2,9 +2,12 @@ export type UserStatus = 'enabled' | 'disabled' | 'locked'
export interface UserTableRow {
id: string
employeeId?: number | null
name: string
account?: string
employeeNo: string
position?: string | null
professionalTitle?: string | null
phone?: string
email?: string
campus: string
@@ -14,16 +17,21 @@ export interface UserTableRow {
role: string
roleNames?: string[]
roleIds?: string[]
dataScope?: string
passwordChangeRequired?: boolean
status: UserStatus
version?: number
}
export interface UserEditorForm {
id?: string
employeeId?: number | null
username: string
password: string
displayName: string
employeeNo: string
position: string
professionalTitle: string
phone: string
phoneMasked?: boolean
email: string
@@ -33,6 +41,7 @@ export interface UserEditorForm {
status: UserStatus
roleIds: string[]
roleLabels?: string[]
passwordChangeRequired?: boolean
clearPhone?: boolean
clearEmail?: boolean
version?: number